├── tests ├── mockhttpd │ ├── GET │ │ └── index │ └── mockhttpd.sh ├── Makefile ├── run_tests.sh ├── integration.sh └── unit.sh ├── .travis.yml ├── Makefile ├── LICENSE ├── README.md └── ok.sh /tests/mockhttpd/GET/index: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | all: test shellcheck 2 | 3 | test: 4 | ./run_tests.sh 5 | 6 | shellcheck: 7 | shellcheck -e SC2155 ../ok.sh 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: sh 2 | 3 | script: | 4 | PATH=$PATH:$TRAVIS_BUILD_DIR make test 5 | 6 | addons: 7 | apt: 8 | sources: 9 | - debian-sid # Grab shellcheck from the Debian repo (o_O) 10 | packages: 11 | - socat 12 | - shellcheck 13 | 14 | notifications: 15 | email: false 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Install ok.sh; build the website/README; run the tests. 2 | 3 | PROGRAM = ok.sh 4 | DESTDIR = $(HOME) 5 | 6 | install : $(PROGRAM) 7 | cp $(PROGRAM) $(DESTDIR)/bin 8 | chmod 755 $(DESTDIR)/bin/$(PROGRAM) 9 | 10 | test: 11 | make -C tests all 12 | 13 | shellcheck: 14 | make -C tests shellcheck 15 | 16 | readme: 17 | @ printf '\n' > README.md 18 | @ printf '[![Build Status](https://travis-ci.org/whiteinge/ok.sh.svg?branch=master)](https://travis-ci.org/whiteinge/ok.sh)\n' >> README.md 19 | @ $(PROGRAM) help >> README.md 20 | @ printf '\n## Table of Contents\n' >> README.md 21 | @ $(PROGRAM) _all_funcs pretty=0 | xargs -n1 -I@ sh -c '[ @ = _main ] && exit; printf "* [@](#@)\n"' >> README.md 22 | @ printf '\n' >> README.md 23 | @ $(PROGRAM) _all_funcs pretty=0 | xargs -n1 -I@ sh -c '[ @ = _main ] && exit; $(PROGRAM) help @; printf "\n"' >> README.md 24 | 25 | preview: 26 | @ pandoc -f markdown_github < README.md > README.html 27 | -------------------------------------------------------------------------------- /tests/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | FAILED_TESTS=0 4 | 5 | _main() { 6 | local socat_pid 7 | 8 | printf 'Running unit tests.\n' 9 | run_tests "./unit.sh" 10 | 11 | 12 | socat tcp-l:8011,crlf,reuseaddr,fork EXEC:./mockhttpd/mockhttpd.sh & 13 | socat_pid=$! 14 | 15 | trap ' 16 | excode=$?; trap - EXIT; 17 | kill '"$socat_pid"' 18 | exit $excode 19 | ' INT TERM EXIT 20 | 21 | 22 | printf 'Running integration tests.\n' 23 | run_tests "./integration.sh" 24 | 25 | exit $FAILED_TESTS 26 | } 27 | 28 | run_tests() { 29 | # Find all the test functions in a file and run each one 30 | # 31 | local fname="${1?:File name is required.}" 32 | # The file containing the tests to run. 33 | 34 | local funcs="$(awk '/^test_[a-zA-Z0-9_]+\s*\(\)/ { 35 | sub(/\(\)$/, "", $1); print $1 }' "$fname")" 36 | 37 | for func in $funcs; do 38 | "$fname" "$func" 39 | [ $? -ne 0 ] && FAILED_TESTS=$(( $FAILED_TESTS + 1 )); 40 | done 41 | } 42 | 43 | _main "$@" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Seth House 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /tests/integration.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Integration tests for the ok.sh script. 3 | 4 | SCRIPT='ok.sh' 5 | export OK_SH_URL='localhost:8011' 6 | 7 | _main() { 8 | local cmd ret 9 | 10 | cmd="$1" && shift 11 | "$cmd" "$@" 12 | ret=$? 13 | 14 | [ $ret -eq 0 ] || printf 'Fail: %s\n' "$cmd" 1>&2 15 | exit $ret 16 | } 17 | 18 | test_get_404() { 19 | $SCRIPT _get /path/does/not/exist 2>/dev/null 20 | local ret=$? 21 | 22 | if [ "$ret" -eq 1 ]; then 23 | return 0 24 | else 25 | printf 'Return code for 404 is "%s"; expected 1.\n' "$ret" 26 | return 1 27 | fi 28 | } 29 | 30 | test_get_500() { 31 | $SCRIPT _get /test_error 2>/dev/null 32 | local ret=$? 33 | 34 | if [ "$ret" -eq 1 ]; then 35 | return 0 36 | else 37 | printf 'Return code for 500 is "%s"; expected 1.\n' "$ret" 38 | return 1 39 | fi 40 | } 41 | 42 | test_pagination_follow() { 43 | local outx out expected 44 | 45 | outx=$($SCRIPT _get /test_pagination | tr -d "\r"; echo x) 46 | out="${outx%x}" 47 | 48 | expected='Current page: 1 49 | Current page: 2 50 | Current page: 3 51 | Current page: 4 52 | ' 53 | 54 | if [ "$out" = "$expected" ]; then 55 | return 0 56 | else 57 | printf 'Bad pagination output. Got "%s"; expected "%s"\n' "$out" "$expected" 58 | return 1 59 | fi 60 | } 61 | 62 | test_pagination_follow_subset() { 63 | local outx out expected 64 | 65 | outx=$($SCRIPT _get '/test_pagination?page=3' | tr -d "\r"; echo x) 66 | out="${outx%x}" 67 | 68 | expected='Current page: 3 69 | Current page: 4 70 | ' 71 | 72 | if [ "$out" = "$expected" ]; then 73 | return 0 74 | else 75 | printf 'Bad pagination output. Got "%s"; expected "%s"\n' "$out" "$expected" 76 | return 1 77 | fi 78 | } 79 | 80 | test_pagination_nofollow() { 81 | local outx out expected 82 | 83 | outx=$($SCRIPT _get /test_pagination _follow_next=0 | tr -d "\r"; echo x) 84 | out="${outx%x}" 85 | 86 | expected='Current page: 1 87 | ' 88 | 89 | if [ "$out" = "$expected" ]; then 90 | return 0 91 | else 92 | printf 'Bad pagination output. Got "%s"; expected "%s"\n' "$out" "$expected" 93 | return 1 94 | fi 95 | } 96 | 97 | test_pagination_follow_limit() { 98 | local outx out expected 99 | 100 | outx=$($SCRIPT _get /test_pagination _follow_next_limit=1 | tr -d "\r"; echo x) 101 | out="${outx%x}" 102 | 103 | expected='Current page: 1 104 | Current page: 2 105 | ' 106 | 107 | if [ "$out" = "$expected" ]; then 108 | return 0 109 | else 110 | printf 'Bad pagination output. Got "%s"; expected "%s"\n' "$out" "$expected" 111 | return 1 112 | fi 113 | } 114 | 115 | _main "$@" 116 | -------------------------------------------------------------------------------- /tests/mockhttpd/mockhttpd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # HTTP server to output JSON files for certain paths 3 | # 4 | # Usage: 5 | # socat TCP-L:8011,crlf,reuseaddr,fork EXEC:./mockhttpd.sh 6 | 7 | BASEDIR="$(dirname $0)" 8 | OK_MPORT="${OK_MPORT:=8011}" 9 | 10 | response() { 11 | # Format an HTTP response 12 | # 13 | local code="${1:?Status code required.}" 14 | # The HTTP status code. 15 | local body="${2:?Response body required.}" 16 | # The HTTP response body. 17 | 18 | local text 19 | 20 | case $code in 21 | 200) text='OK';; 22 | 404) text='NOT FOUND';; 23 | 500) text='INTERNAL SERVER ERROR';; 24 | 501) text='NOT IMPLEMENTED';; 25 | esac 26 | 27 | printf 'HTTP/1.1 %s %s\n\n%s\n' "$code" "$text" "$body" 28 | exit 29 | } 30 | 31 | find_response() { 32 | # Find the stored HTTP response for a URL path 33 | # 34 | # The path-to-file mapping is stored in the index files in each method 35 | # directory. 36 | # 37 | local method="${1:?Method is required.}" 38 | # The HTTP method; looks for a corresponding directory containing a file 39 | # named 'index'. 40 | local path="${2:?Path is required.}" 41 | # The request path 42 | 43 | local mdir="${BASEDIR}/${method}" 44 | local mindex="${mdir}/index" 45 | 46 | if [ ! -d "$mdir" ]; then 47 | response 501 "Directory ${mdir} is missing." 48 | fi 49 | 50 | if [ ! -r "$mindex" ]; then 51 | response 500 "Index file ${mindex} is missing." 52 | fi 53 | 54 | local rfile=$(awk -v "path=${path}" \ 55 | '$1 == path { print $2; exit }' "${mindex}") 56 | local rfile_path="${mdir}/${rfile}" 57 | 58 | if [ -z "$rfile" ]; then 59 | response 404 "Saved response for ${path} not found in ${method}/index." 60 | fi 61 | 62 | if [ ! -r "$rfile_path" ]; then 63 | response 404 "Expected file ${rfile_path} not found." 64 | else 65 | cat "$rfile_path" 66 | fi 67 | } 68 | 69 | pagination() { 70 | # Mimic GitHub's next/prev Link header 71 | # 72 | local path="${1:?Path is required.}" 73 | # The HTTP request path. 74 | 75 | awk -v "path=${path}" -v "port=${OK_MPORT}" ' 76 | function genlink(num, rel) { 77 | return "; rel=\"" rel "\"" 79 | } 80 | 81 | function genlinks(curnum, first, last, links) { 82 | first = 1; last = 4; links = "" 83 | 84 | links = genlink(first, "first") 85 | links = links ", " genlink(last, "last") 86 | 87 | if (curnum != first) links = links ", " genlink(curnum - 1, "prev") 88 | if (curnum != last) links = links ", " genlink(curnum + 1, "next") 89 | 90 | return links 91 | } 92 | 93 | BEGIN { 94 | page_idx = match(path, /\?page=[0-9]+/) 95 | 96 | if (page_idx == 0) { 97 | page_num = 1 98 | } else { 99 | page_num = substr(path, page_idx + length("?page=")) 100 | } 101 | 102 | links = genlinks(page_num) 103 | 104 | printf("HTTP/1.1 200 OK\n") 105 | printf("Link: %s\n", links) 106 | printf("\nCurrent page: %s\n", page_num) 107 | } 108 | ' 109 | } 110 | 111 | main() { 112 | # stdin is the request; stdout is the response. 113 | 114 | local method path proto 115 | read -r method path proto 116 | 117 | printf 'Processing %s request for %s\n' "$method" "$path" 1>&2 118 | 119 | case $path in 120 | /test_error) response 500 'Server-side error';; 121 | /test_pagination*) pagination "$path";; 122 | *) find_response "$method" "$path";; 123 | esac 124 | } 125 | 126 | main "$@" 127 | -------------------------------------------------------------------------------- /tests/unit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Unit tests for the ok.sh script. 3 | 4 | SCRIPT="ok.sh" 5 | JQ="${OK_SH_JQ_BIN:-jq}" 6 | JQ_V="$(jq --version 2>&1 | awk '{ print $3 }')" 7 | 8 | _main() { 9 | local cmd ret 10 | 11 | cmd="$1" && shift 12 | "$cmd" "$@" 13 | ret=$? 14 | 15 | [ $ret -eq 0 ] || printf 'Fail: %s\n' "$cmd" 1>&2 16 | exit $ret 17 | } 18 | 19 | test_format_json() { 20 | # Test output without filtering through jq. 21 | 22 | local output 23 | local is_fail=0 24 | 25 | $SCRIPT -j _format_json foo=Foo bar=123 baz=true qux=Qux=Qux quux='Multi-line 26 | string' | { 27 | read -r output 28 | 29 | printf '%s\n' "$output" | grep -q -E '^{' || { 30 | printf 'JSON does not start with a { char.\n'; is_fail=1 ;} 31 | printf '%s\n' "$output" | grep -q -E '}$' || { 32 | printf 'JSON does not end with a } char.\n'; is_fail=1 ;} 33 | printf '%s\n' "$output" | grep -q -E '"foo": "Foo"' || { 34 | printf 'JSON does not contain "foo": "Foo" text.\n'; is_fail=1 ;} 35 | printf '%s\n' "$output" | grep -q -E '"Multi-line\\nstring"' || { 36 | printf 'JSON does not have properly formatted multiline string.\n'; is_fail=1 ;} 37 | 38 | if [ "$is_fail" -ne 1 ] ; then 39 | return 0 40 | else 41 | printf 'Unexpected JSON output: `%s`\n' "$output" 42 | return 1 43 | fi 44 | } 45 | } 46 | 47 | test_format_urlencode() { 48 | # _format_urlencode 49 | 50 | local output num_params 51 | local is_fail=0 52 | 53 | $SCRIPT _format_urlencode foo='Foo Foo' bar='&/Bar/' | { 54 | read -r output 55 | 56 | printf '%s\n' "$output" | grep -q -E 'foo=Foo%20Foo' || { 57 | printf 'Urlencoded output malformed foo section.\n'; is_fail=1 ;} 58 | 59 | printf '%s\n' "$output" | grep -q -E 'bar=%3CBar%3E%26%2FBar%2F' || { 60 | printf 'Urlencoded output malformed bar section.\n'; is_fail=1 ;} 61 | 62 | num_params="$(printf '%s\n' "$output" | awk -F'&' '{ print NF }')" 63 | if [ "$num_params" -ne 2 ] ; then 64 | printf 'Urlencoded output has %s sections; expected 2.\n'\ 65 | "$num_params" 66 | is_fail=1 67 | fi 68 | 69 | if [ "$is_fail" -ne 1 ] ; then 70 | return 0 71 | else 72 | printf 'Unexpected urlencoded output\n' "$output" 73 | return 1 74 | fi 75 | } 76 | } 77 | 78 | test_format_json_jq() { 79 | # Test output after filtering through jq. 80 | 81 | local output keys vals expected_out 82 | 83 | $SCRIPT _format_json foo=Foo bar=123 baz=true qux=Qux=Qux quux='Multi-line 84 | string' | { 85 | read -r output 86 | 87 | keys=$(printf '%s\n' "$output" | jq -r -c 'keys | .[]' | sort | paste -s -d',' -) 88 | vals=$(printf '%s\n' "$output" | jq -r -c '.[]' | sort | paste -s -d',' -) 89 | 90 | if [ 'bar,baz,foo,quux,qux' = "$keys" ] && [ '123,Foo,Multi-line,Qux=Qux,string,true' = "$vals" ] ; then 91 | return 0 92 | else 93 | printf 'Expected output does not match output: `%s` != `%s`\n' \ 94 | "$expected_out" "$output" 95 | return 1 96 | fi 97 | } 98 | } 99 | 100 | test_filter_json_args() { 101 | local json out 102 | json='["foo", "bar", true, {"qux": "Qux"}]' 103 | 104 | printf '%s\n' "$json" | $SCRIPT _filter_json 'length' | { 105 | read -r out 106 | 107 | if [ 4 -eq "$out" ] ; then 108 | return 0 109 | else 110 | printf 'Expected output does not match output: `%s` != `%s`\n' \ 111 | "$expected_out" "$out" 112 | return 1 113 | fi 114 | } 115 | } 116 | 117 | test_filter_json_pipe() { 118 | # Test for issue #16. 119 | 120 | local out 121 | local json='[{"name": "Foo"}]' 122 | local expected_out='Foo' 123 | 124 | printf '%s\n' "$json" | $SCRIPT _filter_json '.[] | .["name"]' | { 125 | read -r out 126 | 127 | if [ "$expected_out" = "$out" ] ; then 128 | return 0 129 | else 130 | printf 'Expected output does not match output: `%s` != `%s`\n' \ 131 | "$expected_out" "$out" 132 | return 1 133 | fi 134 | } 135 | } 136 | 137 | test_response_headers() { 138 | # Test that process response outputs headers in deterministic order. 139 | 140 | local baz bar foo 141 | 142 | printf 'HTTP/1.1 200 OK 143 | Server: example.com 144 | Foo: Foo! 145 | Bar: Bar! 146 | Baz: Baz! 147 | 148 | Hi\n' | $SCRIPT _response Baz Bad Foo | { 149 | read -r baz 150 | read -r bar # Ensure unfound items are blank. 151 | read -r foo 152 | 153 | ret=0 154 | [ "$baz" = 'Baz!' ] || { ret=1; printf '`Baz!` != `%s`\n' "$baz"; } 155 | [ "$bar" = '' ] || { ret=1; printf '`` != `%s`\n' "$bar"; } 156 | [ "$foo" = 'Foo!' ] || { ret=1; printf '`Foo!` != `%s`\n' "$foo"; } 157 | 158 | return $ret 159 | } 160 | } 161 | 162 | _main "$@" 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 4 | [![Build Status](https://travis-ci.org/whiteinge/ok.sh.svg?branch=master)](https://travis-ci.org/whiteinge/ok.sh) 5 | # A GitHub API client library written in POSIX sh 6 | 7 | ## Requirements 8 | 9 | * A POSIX environment (tested against Busybox v1.19.4) 10 | * curl (tested against 7.32.0) 11 | 12 | ## Optional requirements 13 | 14 | * jq (tested against 1.3) 15 | If jq is not installed commands will output raw JSON; if jq is installed 16 | the output will be formatted and filtered for use with other shell tools. 17 | 18 | ## Setup 19 | 20 | Authentication credentials are read from a `~/.netrc` file. 21 | Generate the token on GitHub under "Account Settings -> Applications". 22 | Restrict permissions on that file with `chmod 600 ~/.netrc`! 23 | 24 | machine api.github.com 25 | login 26 | password 27 | 28 | ## Configuration 29 | 30 | The following environment variables may be set to customize ok.sh. 31 | 32 | * OK_SH_URL=https://api.github.com 33 | Base URL for GitHub or GitHub Enterprise. 34 | * OK_SH_ACCEPT=application/vnd.github.v3+json 35 | The 'Accept' header to send with each request. 36 | * OK_SH_JQ_BIN=jq 37 | The name of the jq binary, if installed. 38 | * OK_SH_VERBOSE=0 39 | The debug logging verbosity level. Same as the verbose flag. 40 | * OK_SH_RATE_LIMIT=0 41 | Output current GitHub rate limit information to stderr. 42 | * OK_SH_DESTRUCTIVE=0 43 | Allow destructive operations without prompting for confirmation. 44 | 45 | ### _main() 46 | 47 | ## Usage 48 | 49 | Usage: `ok.sh [] (command [, ...])` 50 | 51 | ok.sh -h # Short, usage help text. 52 | ok.sh help # All help text. Warning: long! 53 | ok.sh help command # Command-specific help text. 54 | ok.sh command # Run a command with and without args. 55 | ok.sh command foo bar baz=Baz qux='Qux arg here' 56 | 57 | See the full list of commands below. 58 | 59 | Flags _must_ be the first argument to `ok.sh`, before `command`. 60 | 61 | Flag | Description 62 | ---- | ----------- 63 | -V | Show version. 64 | -h | Show this screen. 65 | -j | Output raw JSON; don't process with jq. 66 | -q | Quiet; don't print to stdout. 67 | -r | Print current GitHub API rate limit to stderr. 68 | -v | Logging output; specify multiple times: info, debug, trace. 69 | -x | Enable xtrace debug logging. 70 | -y | Answer 'yes' to any prompts. 71 | 72 | ## Utility and request/response commands 73 | 74 | _all_funcs, _main, _log, _helptext, _format_json, _format_urlencode, 75 | _filter_json, _get_mime_type, _get_confirm, _opts_filter, _opts_pagination, 76 | _opts_qs, _request, _response, _get, _post, _delete 77 | 78 | ## GitHub commands 79 | 80 | help, show_scopes, org_repos, org_teams, list_repos, create_repo, delete_repo, 81 | list_releases, release, create_release, delete_release, release_assets, 82 | upload_asset, list_milestones, list_issues, user_issues, org_issues 83 | 84 | ## Table of Contents 85 | * [help](#help) 86 | * [_all_funcs](#_all_funcs) 87 | * [_log](#_log) 88 | * [_helptext](#_helptext) 89 | * [_format_json](#_format_json) 90 | * [_format_urlencode](#_format_urlencode) 91 | * [_filter_json](#_filter_json) 92 | * [_get_mime_type](#_get_mime_type) 93 | * [_get_confirm](#_get_confirm) 94 | * [_opts_filter](#_opts_filter) 95 | * [_opts_pagination](#_opts_pagination) 96 | * [_opts_qs](#_opts_qs) 97 | * [_request](#_request) 98 | * [_response](#_response) 99 | * [_get](#_get) 100 | * [_post](#_post) 101 | * [_delete](#_delete) 102 | * [show_scopes](#show_scopes) 103 | * [org_repos](#org_repos) 104 | * [org_teams](#org_teams) 105 | * [list_repos](#list_repos) 106 | * [create_repo](#create_repo) 107 | * [delete_repo](#delete_repo) 108 | * [list_releases](#list_releases) 109 | * [release](#release) 110 | * [create_release](#create_release) 111 | * [delete_release](#delete_release) 112 | * [release_assets](#release_assets) 113 | * [upload_asset](#upload_asset) 114 | * [list_milestones](#list_milestones) 115 | * [list_issues](#list_issues) 116 | * [user_issues](#user_issues) 117 | * [org_issues](#org_issues) 118 | 119 | ### help() 120 | 121 | Output the help text for a command 122 | 123 | Usage: 124 | 125 | help commandname 126 | 127 | Positional arguments 128 | 129 | * fname : `$1` 130 | Function name to search for; if omitted searches whole file. 131 | 132 | ### _all_funcs() 133 | 134 | List all functions found in the current file in the order they appear 135 | 136 | Keyword arguments 137 | 138 | * pretty : `1` 139 | `0` output one function per line; `1` output a formatted paragraph. 140 | * public : `1` 141 | `0` do not output public functions. 142 | * private : `1` 143 | `0` do not output private functions. 144 | 145 | ### _log() 146 | 147 | A lightweight logging system based on file descriptors 148 | 149 | Usage: 150 | 151 | _log debug 'Starting the combobulator!' 152 | 153 | Positional arguments 154 | 155 | * level : `$1` 156 | The level for a given log message. (info or debug) 157 | * message : `$2` 158 | The log message. 159 | 160 | ### _helptext() 161 | 162 | Extract contiguous lines of comments and function params as help text 163 | 164 | Indentation will be ignored. She-bangs will be ignored. The _main() 165 | function will be ignored. Local variable declarations and their default 166 | values can also be pulled in as documentation. Exits upon encountering 167 | the first blank line. 168 | 169 | Exported environment variables can be used for string interpolation in 170 | the extracted commented text. 171 | 172 | Input 173 | 174 | * (stdin) 175 | The text of a function body to parse. 176 | 177 | Positional arguments 178 | 179 | * name : `$1` 180 | A file name to parse. 181 | 182 | ### _format_json() 183 | 184 | Create formatted JSON from name=value pairs 185 | 186 | Usage: 187 | ``` 188 | _format_json foo=Foo bar=123 baz=true qux=Qux=Qux quux='Multi-line 189 | string' 190 | ``` 191 | 192 | Return: 193 | ``` 194 | {"bar":123,"qux":"Qux=Qux","foo":"Foo","quux":"Multi-line\nstring","baz":true} 195 | ``` 196 | 197 | Tries not to quote numbers and booleans. If jq is installed it will also 198 | validate the output. 199 | 200 | Positional arguments 201 | 202 | * $1 - $9 203 | Each positional arg must be in the format of `name=value` which will be 204 | added to a single, flat JSON object. 205 | 206 | ### _format_urlencode() 207 | 208 | URL encode and join name=value pairs 209 | 210 | Usage: 211 | ``` 212 | _format_urlencode foo='Foo Foo' bar='&/Bar/' 213 | ``` 214 | 215 | Return: 216 | ``` 217 | foo=Foo%20Foo&bar=%3CBar%3E%26%2FBar%2F 218 | ``` 219 | 220 | Ignores pairs if the value begins with an underscore. 221 | 222 | ### _filter_json() 223 | 224 | Filter JSON input using jq; outputs raw JSON if jq is not installed 225 | 226 | Usage: 227 | 228 | _filter_json '.[] | "\(.foo)"' < something.json 229 | 230 | * (stdin) 231 | JSON input. 232 | * _filter : `$1` 233 | A string of jq filters to apply to the input stream. 234 | 235 | ### _get_mime_type() 236 | 237 | Guess the mime type for a file based on the file extension 238 | 239 | Usage: 240 | 241 | local mime_type 242 | _get_mime_type "foo.tar"; printf 'mime is: %s' "$mime_type" 243 | 244 | Sets the global variable `mime_type` with the result. (If this function 245 | is called from within a function that has declared a local variable of 246 | that name it will update the local copy and not set a global.) 247 | 248 | Positional arguments 249 | 250 | * filename : `$1` 251 | The full name of the file, with exension. 252 | 253 | ### _get_confirm() 254 | 255 | Prompt the user for confirmation 256 | 257 | Usage: 258 | 259 | local confirm; _get_confirm 260 | [ "$confirm" -eq 1 ] && printf 'Good to go!\n' 261 | 262 | If global confirmation is set via `$OK_SH_DESTRUCTIVE` then the user 263 | is not prompted. Assigns the user's confirmation to the `confirm` global 264 | variable. (If this function is called within a function that has a local 265 | variable of that name, the local variable will be updated instead.) 266 | 267 | Positional arguments 268 | 269 | * message : `$1` 270 | The message to prompt the user with. 271 | 272 | ### _opts_filter() 273 | 274 | Extract common jq filter keyword options and assign to vars 275 | 276 | Usage: 277 | 278 | local filter 279 | _opts_filter "$@" 280 | 281 | ### _opts_pagination() 282 | 283 | Extract common pagination keyword options and assign to vars 284 | 285 | Usage: 286 | 287 | local _follow_next 288 | _opts_pagination "$@" 289 | 290 | ### _opts_qs() 291 | 292 | Format a querystring to append to an URL or a blank string 293 | 294 | Usage: 295 | 296 | local qs 297 | _opts_qs "$@" 298 | _get "/some/path" 299 | 300 | ### _request() 301 | 302 | A wrapper around making HTTP requests with curl 303 | 304 | Usage: 305 | ``` 306 | _request /repos/:owner/:repo/issues 307 | printf '{"title": "%s", "body": "%s"}\n' "Stuff" "Things" \ 308 | | _request /repos/:owner/:repo/issues | jq -r '.[url]' 309 | printf '{"title": "%s", "body": "%s"}\n' "Stuff" "Things" \ 310 | | _request /repos/:owner/:repo/issues method=PUT | jq -r '.[url]' 311 | ``` 312 | 313 | Input 314 | 315 | * (stdin) 316 | Data that will be used as the request body. 317 | 318 | Positional arguments 319 | 320 | * path : `$1` 321 | The URL path for the HTTP request. 322 | Must be an absolute path that starts with a `/` or a full URL that 323 | starts with http(s). Absolute paths will be append to the value in 324 | `$OK_SH_URL`. 325 | 326 | Keyword arguments 327 | 328 | * method : `'GET'` 329 | The method to use for the HTTP request. 330 | * content_type : `'application/json'` 331 | The value of the Content-Type header to use for the request. 332 | 333 | ### _response() 334 | 335 | Process an HTTP response from curl 336 | 337 | Output only headers of interest followed by the response body. Additional 338 | processing is performed on select headers to make them easier to work 339 | with in sh. See below. 340 | 341 | Usage: 342 | ``` 343 | _request /some/path | _response status_code ETag Link_next 344 | curl -isS example.com/some/path | _response status_code status_text | { 345 | local status_code status_text 346 | read -r status_code 347 | read -r status_text 348 | } 349 | ``` 350 | 351 | Header reformatting 352 | 353 | * HTTP Status 354 | The HTTP line is split into `http_version`, `status_code`, and 355 | `status_text` variables. 356 | * ETag 357 | The surrounding quotes are removed. 358 | * Link 359 | Each URL in the Link header is expanded with the URL type appended to 360 | the name. E.g., `Link_first`, `Link_last`, `Link_next`. 361 | 362 | Positional arguments 363 | 364 | * $1 - $9 365 | Each positional arg is the name of an HTTP header. Each header value is 366 | output in the same order as each argument; each on a single line. A 367 | blank line is output for headers that cannot be found. 368 | 369 | ### _get_mime_type() 370 | 371 | Guess the mime type for a file based on the file extension 372 | 373 | Usage: 374 | 375 | local mime_type 376 | _get_mime_type "foo.tar"; printf 'mime is: %s' "$mime_type" 377 | 378 | Sets the global variable `mime_type` with the result. (If this function 379 | is called from within a function that has declared a local variable of 380 | that name it will update the local copy and not set a global.) 381 | 382 | Positional arguments 383 | 384 | * filename : `$1` 385 | The full name of the file, with exension. 386 | 387 | ### _post() 388 | 389 | A wrapper around _request() for commoon POST / PUT patterns 390 | 391 | Usage: 392 | 393 | _format_json foo=Foo bar=Bar | _post /some/path 394 | _format_json foo=Foo bar=Bar | _post /some/path method='PUT' 395 | _post /some/path filename=somearchive.tar 396 | _post /some/path filename=somearchive.tar mime_type=application/x-tar 397 | _post /some/path filename=somearchive.tar \ 398 | mime_type=$(file -b --mime-type somearchive.tar) 399 | 400 | Input 401 | 402 | * (stdin) 403 | Optional. See the `filename` argument also. 404 | Data that will be used as the request body. 405 | 406 | Positional arguments 407 | 408 | * path : `$1` 409 | The HTTP path or URL to pass to _request(). 410 | 411 | Keyword arguments 412 | 413 | * method : `'POST'` 414 | The method to use for the HTTP request. 415 | * : `filename` 416 | Optional. See the `stdin` option above also. 417 | Takes precedence over any data passed as stdin and loads a file off the 418 | file system to serve as the request body. 419 | * : `mime_type` 420 | The value of the Content-Type header to use for the request. 421 | If the `filename` argument is given this value will be guessed from the 422 | file extension. If the `filename` argument is not given (i.e., using 423 | stdin) this value defaults to `application/json`. Specifying this 424 | argument overrides all other defaults or guesses. 425 | 426 | ### _delete() 427 | 428 | A wrapper around _request() for common DELETE patterns 429 | 430 | Usage: 431 | 432 | _delete '/some/url' 433 | 434 | Return: 0 for success; 1 for failure. 435 | 436 | Positional arguments 437 | 438 | * url : `$1` 439 | The URL to send the DELETE request to. 440 | 441 | ### show_scopes() 442 | 443 | Show the permission scopes for the currently authenticated user 444 | 445 | Usage: 446 | 447 | show_scopes 448 | 449 | ### org_repos() 450 | 451 | List organization repositories 452 | 453 | Usage: 454 | 455 | org_repos myorg 456 | org_repos myorg type=private per_page=10 457 | org_repos myorg _filter='.[] | "\(.name)\t\(.owner.login)"' 458 | 459 | Positional arguments 460 | 461 | * org : `$1` 462 | Organization GitHub login or id for which to list repos. 463 | 464 | Keyword arguments 465 | 466 | * _filter : `'.[] | "\(.name)\t\(.ssh_url)"'` 467 | A jq filter to apply to the return data. 468 | 469 | Querystring arguments may also be passed as keyword arguments: 470 | per_page, type 471 | 472 | ### org_teams() 473 | 474 | List teams 475 | 476 | Usage: 477 | 478 | org_teams org 479 | 480 | Positional arguments 481 | 482 | * org : `$1` 483 | Organization GitHub login or id. 484 | 485 | Keyword arguments 486 | 487 | * _filter : `'.[] | "\(.name)\t\(.id)\t\(.permission)"'` 488 | A jq filter to apply to the return data. 489 | 490 | ### list_repos() 491 | 492 | List user repositories 493 | 494 | Usage: 495 | 496 | list_repos 497 | list_repos user 498 | 499 | Positional arguments 500 | 501 | * user : `$1` 502 | Optional GitHub user login or id for which to list repos. 503 | 504 | Keyword arguments 505 | 506 | * _filter : `'.[] | "\(.name)\t\(.html_url)"'` 507 | A jq filter to apply to the return data. 508 | 509 | Querystring arguments may also be passed as keyword arguments: 510 | per_page, type, sort, direction 511 | 512 | ### create_repo() 513 | 514 | Create a repository for a user or organization 515 | 516 | Usage: 517 | 518 | create_repo foo 519 | create_repo bar description='Stuff and things' homepage='example.com' 520 | create_repo baz organization=myorg 521 | 522 | Positional arguments 523 | 524 | * name : `$1` 525 | Name of the new repo 526 | 527 | Keyword arguments 528 | 529 | * _filter : `'.[] | "\(.name)\t\(.html_url)"'` 530 | A jq filter to apply to the return data. 531 | 532 | POST data may also be passed as keyword arguments: 533 | description, homepage, private, has_issues, has_wiki, has_downloads, 534 | organization, team_id, auto_init, gitignore_template 535 | 536 | ### delete_repo() 537 | 538 | Create a repository for a user or organization 539 | 540 | Usage: 541 | 542 | delete_repo owner repo 543 | 544 | The currently authenticated user must have the `delete_repo` scope. View 545 | current scopes with the `show_scopes()` function. 546 | 547 | Positional arguments 548 | 549 | * owner : `$1` 550 | Name of the new repo 551 | * repo : `$2` 552 | Name of the new repo 553 | 554 | ### list_releases() 555 | 556 | List releases for a repository 557 | 558 | Usage: 559 | 560 | list_releases org repo '\(.assets[0].name)\t\(.name.id)' 561 | 562 | Positional arguments 563 | 564 | * owner : `$1` 565 | A GitHub user or organization. 566 | * repo : `$2` 567 | A GitHub repository. 568 | 569 | Keyword arguments 570 | 571 | * _filter : `'.[] | "\(.name)\t\(.id)\t\(.html_url)"'` 572 | A jq filter to apply to the return data. 573 | 574 | ### release() 575 | 576 | Get a release 577 | 578 | Usage: 579 | 580 | release user repo 1087855 581 | 582 | Positional arguments 583 | 584 | * owner : `$1` 585 | A GitHub user or organization. 586 | * repo : `$2` 587 | A GitHub repository. 588 | * release_id : `$3` 589 | The unique ID of the release; see list_releases. 590 | 591 | Keyword arguments 592 | 593 | * _filter : `'"\(.author.login)\t\(.published_at)"'` 594 | A jq filter to apply to the return data. 595 | 596 | ### create_release() 597 | 598 | Create a release 599 | 600 | Usage: 601 | 602 | create_release org repo v1.2.3 603 | create_release user repo v3.2.1 draft=true 604 | 605 | Positional arguments 606 | 607 | * owner : `$1` 608 | A GitHub user or organization. 609 | * repo : `$2` 610 | A GitHub repository. 611 | * tag_name : `$3` 612 | Git tag from which to create release. 613 | 614 | Keyword arguments 615 | 616 | * _filter : `'"\(.name)\t\(.id)\t\(.html_url)"'` 617 | A jq filter to apply to the return data. 618 | 619 | POST data may also be passed as keyword arguments: 620 | body, draft, name, prerelease, target_commitish 621 | 622 | ### delete_release() 623 | 624 | Delete a release 625 | 626 | Usage: 627 | 628 | delete_release org repo 1087855 629 | 630 | Return: 0 for success; 1 for failure. 631 | 632 | Positional arguments 633 | 634 | * owner : `$1` 635 | A GitHub user or organization. 636 | * repo : `$2` 637 | A GitHub repository. 638 | * release_id : `$3` 639 | The unique ID of the release; see list_releases. 640 | 641 | ### release_assets() 642 | 643 | List release assets 644 | 645 | Usage: 646 | 647 | release_assets user repo 1087855 648 | 649 | Positional arguments 650 | 651 | * owner : `$1` 652 | A GitHub user or organization. 653 | * repo : `$2` 654 | A GitHub repository. 655 | * release_id : `$3` 656 | The unique ID of the release; see list_releases. 657 | 658 | Keyword arguments 659 | 660 | * _filter : `'.[] | "\(.id)\t\(.name)\t\(.updated_at)"'` 661 | A jq filter to apply to the return data. 662 | 663 | ### upload_asset() 664 | 665 | Upload a release asset 666 | 667 | Note, this command requires `jq` to find the release `upload_url`. 668 | 669 | Usage: 670 | 671 | upload_asset username reponame 1087938 \ 672 | foo.tar application/x-tar < foo.tar 673 | 674 | * (stdin) 675 | The contents of the file to upload. 676 | 677 | Positional arguments 678 | 679 | * owner : `$1` 680 | A GitHub user or organization. 681 | * repo : `$2` 682 | A GitHub repository. 683 | * release_id : `$3` 684 | The unique ID of the release; see list_releases. 685 | * name : `$4` 686 | The file name of the asset. 687 | 688 | Keyword arguments 689 | 690 | * _filter : `'"\(.state)\t\(.browser_download_url)"'` 691 | A jq filter to apply to the return data. 692 | 693 | ### list_milestones() 694 | 695 | List milestones for a repository 696 | 697 | Usage: 698 | 699 | list_milestones someuser/somerepo 700 | list_milestones someuser/somerepo state=closed 701 | 702 | Positional arguments 703 | 704 | * repository : `$1` 705 | A GitHub repository. 706 | 707 | Keyword arguments 708 | 709 | * : `_follow_next` 710 | Automatically look for a 'Links' header and follow any 'next' URLs. 711 | * : `_follow_next_limit` 712 | Maximum number of 'next' URLs to follow before stopping. 713 | * _filter : `'.[] | "\(.number)\t\(.open_issues)/\(.closed_issues)\t\(.title)"'` 714 | A jq filter to apply to the return data. 715 | 716 | GitHub querystring arguments may also be passed as keyword arguments: 717 | per_page, state, sort, direction 718 | 719 | ### list_issues() 720 | 721 | List issues for the authenticated user or repository 722 | 723 | Usage: 724 | 725 | list_issues 726 | list_issues someuser/somerepo 727 | list_issues someuser/somerepo state=closed labels=foo,bar 728 | 729 | Positional arguments 730 | 731 | * repository : `$1` 732 | A GitHub repository. 733 | 734 | Keyword arguments 735 | 736 | * : `_follow_next` 737 | Automatically look for a 'Links' header and follow any 'next' URLs. 738 | * : `_follow_next_limit` 739 | Maximum number of 'next' URLs to follow before stopping. 740 | * _filter : `'.[] | "\(.number)\t\(.title)"'` 741 | A jq filter to apply to the return data. 742 | 743 | GitHub querystring arguments may also be passed as keyword arguments: 744 | per_page, milestone, state, assignee, creator, mentioned, labels, sort, 745 | direction, since 746 | 747 | ### user_issues() 748 | 749 | List all issues across owned and member repositories for the authenticated user 750 | 751 | Usage: 752 | 753 | user_issues 754 | user_issues since=2015-60-11T00:09:00Z 755 | 756 | Positional arguments 757 | 758 | * repository : `$1` 759 | A GitHub repository. 760 | 761 | Keyword arguments 762 | 763 | * : `_follow_next` 764 | Automatically look for a 'Links' header and follow any 'next' URLs. 765 | * : `_follow_next_limit` 766 | Maximum number of 'next' URLs to follow before stopping. 767 | * _filter : `'.[] | "\(.number)\t\(.title)"'` 768 | A jq filter to apply to the return data. 769 | 770 | GitHub querystring arguments may also be passed as keyword arguments: 771 | per_page, filter, state, labels, sort, direction, since 772 | 773 | ### org_issues() 774 | 775 | List all issues for a given organization for the authenticated user 776 | 777 | Usage: 778 | 779 | org_issues someorg 780 | 781 | Positional arguments 782 | 783 | * org : `$1` 784 | Organization GitHub login or id. 785 | 786 | Keyword arguments 787 | 788 | * : `_follow_next` 789 | Automatically look for a 'Links' header and follow any 'next' URLs. 790 | * : `_follow_next_limit` 791 | Maximum number of 'next' URLs to follow before stopping. 792 | * _filter : `'.[] | "\(.number)\t\(.title)"'` 793 | A jq filter to apply to the return data. 794 | 795 | GitHub querystring arguments may also be passed as keyword arguments: 796 | per_page, filter, state, labels, sort, direction, since 797 | 798 | -------------------------------------------------------------------------------- /ok.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # # A GitHub API client library written in POSIX sh 3 | # 4 | # ## Requirements 5 | # 6 | # * A POSIX environment (tested against Busybox v1.19.4) 7 | # * curl (tested against 7.32.0) 8 | # 9 | # ## Optional requirements 10 | # 11 | # * jq (tested against 1.3) 12 | # If jq is not installed commands will output raw JSON; if jq is installed 13 | # the output will be formatted and filtered for use with other shell tools. 14 | # 15 | # ## Setup 16 | # 17 | # Authentication credentials are read from a `~/.netrc` file. 18 | # Generate the token on GitHub under "Account Settings -> Applications". 19 | # Restrict permissions on that file with `chmod 600 ~/.netrc`! 20 | # 21 | # machine api.github.com 22 | # login 23 | # password 24 | # 25 | # ## Configuration 26 | # 27 | # The following environment variables may be set to customize ${NAME}. 28 | # 29 | # * OK_SH_URL=${OK_SH_URL} 30 | # Base URL for GitHub or GitHub Enterprise. 31 | # * OK_SH_ACCEPT=${OK_SH_ACCEPT} 32 | # The 'Accept' header to send with each request. 33 | # * OK_SH_JQ_BIN=${OK_SH_JQ_BIN} 34 | # The name of the jq binary, if installed. 35 | # * OK_SH_VERBOSE=${OK_SH_VERBOSE} 36 | # The debug logging verbosity level. Same as the verbose flag. 37 | # * OK_SH_RATE_LIMIT=${OK_SH_RATE_LIMIT} 38 | # Output current GitHub rate limit information to stderr. 39 | # * OK_SH_DESTRUCTIVE=${OK_SH_DESTRUCTIVE} 40 | # Allow destructive operations without prompting for confirmation. 41 | 42 | export NAME=$(basename "$0") 43 | export VERSION='0.1.0' 44 | 45 | export OK_SH_URL=${OK_SH_URL:-'https://api.github.com'} 46 | export OK_SH_ACCEPT=${OK_SH_ACCEPT:-'application/vnd.github.v3+json'} 47 | export OK_SH_JQ_BIN="${OK_SH_JQ_BIN:-jq}" 48 | export OK_SH_VERBOSE="${OK_SH_VERBOSE:-0}" 49 | export OK_SH_RATE_LIMIT="${OK_SH_RATE_LIMIT:-0}" 50 | export OK_SH_DESTRUCTIVE="${OK_SH_DESTRUCTIVE:-0}" 51 | 52 | # Detect if jq is installed. 53 | command -v "$OK_SH_JQ_BIN" 1>/dev/null 2>/dev/null 54 | NO_JQ=$? 55 | 56 | # Customizable logging output. 57 | exec 4>/dev/null 58 | exec 5>/dev/null 59 | exec 6>/dev/null 60 | export LINFO=4 # Info-level log messages. 61 | export LDEBUG=5 # Debug-level log messages. 62 | export LSUMMARY=6 # Summary output. 63 | 64 | # ## Main 65 | # Generic functions not necessarily specific to working with GitHub. 66 | 67 | # ### Help 68 | # Functions for fetching and formatting help text. 69 | 70 | help() { 71 | # Output the help text for a command 72 | # 73 | # Usage: 74 | # 75 | # help commandname 76 | # 77 | # Positional arguments 78 | # 79 | local fname="$1" 80 | # Function name to search for; if omitted searches whole file. 81 | 82 | if [ $# -gt 0 ]; then 83 | awk -v fname="^$fname" '$0 ~ fname, /^}/ { print }' "$0" | _helptext 84 | else 85 | _helptext < "$0" 86 | printf '\n' 87 | help __main 88 | fi 89 | } 90 | 91 | _all_funcs() { 92 | # List all functions found in the current file in the order they appear 93 | # 94 | # Keyword arguments 95 | # 96 | local pretty=1 97 | # `0` output one function per line; `1` output a formatted paragraph. 98 | local public=1 99 | # `0` do not output public functions. 100 | local private=1 101 | # `0` do not output private functions. 102 | 103 | for arg in "$@"; do 104 | case $arg in 105 | (pretty=*) pretty="${arg#*=}";; 106 | (public=*) public="${arg#*=}";; 107 | (private=*) private="${arg#*=}";; 108 | esac 109 | done 110 | 111 | awk -v public="$public" -v private="$private" ' 112 | $1 !~ /^__/ && /^[a-zA-Z0-9_]+\s*\(\)/ { 113 | sub(/\(\)$/, "", $1) 114 | if (!public && substr($1, 1, 1) != "_") next 115 | if (!private && substr($1, 1, 1) == "_") next 116 | print $1 117 | } 118 | ' "$0" | { 119 | if [ "$pretty" -eq 1 ] ; then 120 | cat | sed ':a;N;$!ba;s/\n/, /g' | fold -w 79 -s 121 | else 122 | cat 123 | fi 124 | } 125 | } 126 | 127 | __main() { 128 | # Usage: `${NAME} [] (command [, ...])` 129 | # 130 | # ${NAME} -h # Short, usage help text. 131 | # ${NAME} help # All help text. Warning: long! 132 | # ${NAME} help command # Command-specific help text. 133 | # ${NAME} command # Run a command with and without args. 134 | # ${NAME} command foo bar baz=Baz qux='Qux arg here' 135 | # 136 | # See the full list of commands below. 137 | # 138 | # Flags _must_ be the first argument to `${NAME}`, before `command`. 139 | # 140 | # Flag | Description 141 | # ---- | ----------- 142 | # -V | Show version. 143 | # -h | Show this screen. 144 | # -j | Output raw JSON; don't process with jq. 145 | # -q | Quiet; don't print to stdout. 146 | # -r | Print current GitHub API rate limit to stderr. 147 | # -v | Logging output; specify multiple times: info, debug, trace. 148 | # -x | Enable xtrace debug logging. 149 | # -y | Answer 'yes' to any prompts. 150 | 151 | local cmd 152 | local ret 153 | local opt 154 | local OPTARG 155 | local OPTIND 156 | local quiet=0 157 | local temp_dir="/tmp/oksh-${random}-${$}" 158 | local summary_fifo="${temp_dir}/oksh_summary.fifo" 159 | local random 160 | random="$(hexdump -n 2 -e '/2 "%u"' /dev/urandom)" 161 | 162 | # shellcheck disable=SC2154 163 | trap ' 164 | excode=$?; trap - EXIT; 165 | exec 4>&- 166 | exec 5>&- 167 | exec 6>&- 168 | rm -rf '"$temp_dir"' 169 | exit $excode 170 | ' INT TERM EXIT 171 | 172 | while getopts Vhjqrvxy opt; do 173 | case $opt in 174 | V) printf 'Version: %s\n' $VERSION 175 | exit;; 176 | h) help __main 177 | printf '\nAvailable commands:\n\n' 178 | _all_funcs public=0 179 | printf '\n' 180 | _all_funcs private=0 181 | printf '\n' 182 | exit;; 183 | j) NO_JQ=1;; 184 | q) quiet=1;; 185 | r) OK_SH_RATE_LIMIT=1;; 186 | v) OK_SH_VERBOSE=$(( OK_SH_VERBOSE + 1 ));; 187 | x) set -x;; 188 | y) OK_SH_DESTRUCTIVE=1;; 189 | esac 190 | done 191 | shift $(( OPTIND - 1 )) 192 | 193 | if [ -z "$1" ] ; then 194 | printf 'No command given. Available commands:\n\n%s\n' \ 195 | "$(_all_funcs)" 1>&2 196 | exit 1 197 | fi 198 | 199 | [ $OK_SH_VERBOSE -gt 0 ] && exec 4>&2 200 | [ $OK_SH_VERBOSE -gt 1 ] && exec 5>&2 201 | if [ $quiet -eq 1 ]; then 202 | exec 1>/dev/null 2>/dev/null 203 | fi 204 | 205 | if [ "$OK_SH_RATE_LIMIT" -eq 1 ] ; then 206 | mkdir -m 700 "$temp_dir" || { 207 | printf 'failed to create temp_dir\n' >&2; exit 1; 208 | } 209 | mkfifo "$summary_fifo" 210 | # Hold the fifo open so it will buffer input until emptied. 211 | exec 6<>"$summary_fifo" 212 | fi 213 | 214 | # Run the command. 215 | cmd="$1" && shift 216 | _log debug "Running command ${cmd}." 217 | "$cmd" "$@" 218 | ret=$? 219 | _log debug "Command ${cmd} exited with ${?}." 220 | 221 | # Output any summary messages. 222 | if [ "$OK_SH_RATE_LIMIT" -eq 1 ] ; then 223 | cat "$summary_fifo" 1>&2 & 224 | exec 6>&- 225 | fi 226 | 227 | exit $ret 228 | } 229 | 230 | _log() { 231 | # A lightweight logging system based on file descriptors 232 | # 233 | # Usage: 234 | # 235 | # _log debug 'Starting the combobulator!' 236 | # 237 | # Positional arguments 238 | # 239 | local level="${1:?Level is required.}" 240 | # The level for a given log message. (info or debug) 241 | local message="${2:?Message is required.}" 242 | # The log message. 243 | 244 | shift 2 245 | 246 | local lname 247 | 248 | case "$level" in 249 | info) lname='INFO'; level=$LINFO ;; 250 | debug) lname='DEBUG'; level=$LDEBUG ;; 251 | *) printf 'Invalid logging level: %s\n' "$level" ;; 252 | esac 253 | 254 | printf '%s %s: %s\n' "$NAME" "$lname" "$message" 1>&$level 255 | } 256 | 257 | _helptext() { 258 | # Extract contiguous lines of comments and function params as help text 259 | # 260 | # Indentation will be ignored. She-bangs will be ignored. Local variable 261 | # declarations and their default values can also be pulled in as 262 | # documentation. Exits upon encountering the first blank line. 263 | # 264 | # Exported environment variables can be used for string interpolation in 265 | # the extracted commented text. 266 | # 267 | # Input 268 | # 269 | # * (stdin) 270 | # The text of a function body to parse. 271 | 272 | awk ' 273 | NR != 1 && /^\s*#/ { 274 | line=$0 275 | while(match(line, "[$]{[^}]*}")) { 276 | var=substr(line, RSTART+2, RLENGTH -3) 277 | gsub("[$]{"var"}", ENVIRON[var], line) 278 | } 279 | gsub(/^\s*#\s?/, "", line) 280 | print line 281 | } 282 | /^\s*local/ { 283 | sub(/^\s*local /, "") 284 | idx = index($0, "=") 285 | name = substr($0, 1, idx - 1) 286 | val = substr($0, idx + 1) 287 | sub(/"{0,1}\${/, "$", val) 288 | sub(/:.*$/, "", val) 289 | print "* " name " : `" val "`" 290 | } 291 | !NF { exit }' 292 | } 293 | 294 | # ### Request-response 295 | # Functions for making HTTP requests and processing HTTP responses. 296 | 297 | _format_json() { 298 | # Create formatted JSON from name=value pairs 299 | # 300 | # Usage: 301 | # ``` 302 | # _format_json foo=Foo bar=123 baz=true qux=Qux=Qux quux='Multi-line 303 | # string' 304 | # ``` 305 | # 306 | # Return: 307 | # ``` 308 | # {"bar":123,"qux":"Qux=Qux","foo":"Foo","quux":"Multi-line\nstring","baz":true} 309 | # ``` 310 | # 311 | # Tries not to quote numbers and booleans. If jq is installed it will also 312 | # validate the output. 313 | # 314 | # Positional arguments 315 | # 316 | # * $1 - $9 317 | # Each positional arg must be in the format of `name=value` which will be 318 | # added to a single, flat JSON object. 319 | 320 | _log debug "Formatting ${#} parameters as JSON." 321 | 322 | env -i "$@" awk ' 323 | function isnum(x){ return (x == x + 0) } 324 | function isbool(x){ if (x == "true" || x == "false") return 1 } 325 | BEGIN { 326 | delete ENVIRON["AWKPATH"] # GNU addition. 327 | printf("{") 328 | 329 | for (name in ENVIRON) { 330 | val = ENVIRON[name] 331 | 332 | # If not bool or number, quote it. 333 | if (!isbool(val) && !isnum(val)) { 334 | gsub(/"/, "\\\"", val) # Escape double-quotes. 335 | gsub(/\n/, "\\n", val) # Replace newlines with \n text. 336 | val = "\"" val "\"" 337 | } 338 | 339 | printf("%s\"%s\": %s", sep, name, val) 340 | sep = ", " 341 | } 342 | 343 | printf("}\n") 344 | } 345 | ' | _filter_json 346 | } 347 | 348 | _format_urlencode() { 349 | # URL encode and join name=value pairs 350 | # 351 | # Usage: 352 | # ``` 353 | # _format_urlencode foo='Foo Foo' bar='&/Bar/' 354 | # ``` 355 | # 356 | # Return: 357 | # ``` 358 | # foo=Foo%20Foo&bar=%3CBar%3E%26%2FBar%2F 359 | # ``` 360 | # 361 | # Ignores pairs if the value begins with an underscore. 362 | 363 | _log debug "Formatting ${#} parameters as urlencoded" 364 | 365 | env -i "$@" awk ' 366 | function escape(str, c, len, res) { 367 | len = length(str) 368 | res = "" 369 | for (i = 1; i <= len; i += 1) { 370 | c = substr(str, i, 1); 371 | if (c ~ /[0-9A-Za-z]/) 372 | res = res c 373 | else 374 | res = res "%" sprintf("%02X", ord[c]) 375 | } 376 | return res 377 | } 378 | 379 | BEGIN { 380 | for (i = 0; i <= 255; i += 1) ord[sprintf("%c", i)] = i; 381 | 382 | delete ENVIRON["AWKPATH"] # GNU addition. 383 | for (name in ENVIRON) { 384 | if (substr(name, 1, 1) == "_") continue 385 | val = ENVIRON[name] 386 | 387 | printf("%s%s=%s", sep, name, escape(val)) 388 | sep = "&" 389 | } 390 | } 391 | ' 392 | } 393 | 394 | _filter_json() { 395 | # Filter JSON input using jq; outputs raw JSON if jq is not installed 396 | # 397 | # Usage: 398 | # 399 | # _filter_json '.[] | "\(.foo)"' < something.json 400 | # 401 | # * (stdin) 402 | # JSON input. 403 | local _filter="$1" 404 | # A string of jq filters to apply to the input stream. 405 | 406 | _log debug 'Filtering JSON.' 407 | 408 | if [ $NO_JQ -ne 0 ] ; then 409 | cat 410 | return 411 | fi 412 | 413 | "${OK_SH_JQ_BIN}" -c -r "${_filter}" 414 | [ $? -eq 0 ] || printf 'jq parse error; invalid JSON.\n' 1>&2 415 | } 416 | 417 | _get_mime_type() { 418 | # Guess the mime type for a file based on the file extension 419 | # 420 | # Usage: 421 | # 422 | # local mime_type 423 | # _get_mime_type "foo.tar"; printf 'mime is: %s' "$mime_type" 424 | # 425 | # Sets the global variable `mime_type` with the result. (If this function 426 | # is called from within a function that has declared a local variable of 427 | # that name it will update the local copy and not set a global.) 428 | # 429 | # Positional arguments 430 | # 431 | local filename="${1:?Filename is required.}" 432 | # The full name of the file, with exension. 433 | 434 | local ext="${filename#*.}" 435 | 436 | # Taken from Apache's mime.types file (public domain). 437 | case "$ext" in 438 | bz2) mime_type=application/x-bzip2 ;; 439 | exe) mime_type=application/x-msdownload ;; 440 | gz | tgz) mime_type=application/x-gzip ;; 441 | jpg | jpeg | jpe | jfif) mime_type=image/jpeg ;; 442 | json) mime_type=application/json ;; 443 | pdf) mime_type=application/pdf ;; 444 | png) mime_type=image/png ;; 445 | rpm) mime_type=application/x-rpm ;; 446 | svg | svgz) mime_type=image/svg+xml ;; 447 | tar) mime_type=application/x-tar ;; 448 | yaml) mime_type=application/x-yaml ;; 449 | zip) mime_type=application/zip ;; 450 | esac 451 | 452 | _log debug "Guessed mime type of '${mime_type}' for '${filename}'." 453 | } 454 | 455 | _get_confirm() { 456 | # Prompt the user for confirmation 457 | # 458 | # Usage: 459 | # 460 | # local confirm; _get_confirm 461 | # [ "$confirm" -eq 1 ] && printf 'Good to go!\n' 462 | # 463 | # If global confirmation is set via `$OK_SH_DESTRUCTIVE` then the user 464 | # is not prompted. Assigns the user's confirmation to the `confirm` global 465 | # variable. (If this function is called within a function that has a local 466 | # variable of that name, the local variable will be updated instead.) 467 | # 468 | # Positional arguments 469 | # 470 | local message="${1:-Are you sure?}" 471 | # The message to prompt the user with. 472 | 473 | local answer 474 | 475 | if [ "$OK_SH_DESTRUCTIVE" -eq 1 ] ; then 476 | confirm=$OK_SH_DESTRUCTIVE 477 | return 478 | fi 479 | 480 | printf '%s ' "$message" 481 | read -r answer 482 | 483 | ! printf '%s\n' "$answer" | grep -Eq "$(locale yesexpr)" 484 | confirm=$? 485 | } 486 | 487 | _opts_filter() { 488 | # Extract common jq filter keyword options and assign to vars 489 | # 490 | # Usage: 491 | # 492 | # local filter 493 | # _opts_filter "$@" 494 | 495 | for arg in "$@"; do 496 | case $arg in 497 | (_filter=*) _filter="${arg#*=}";; 498 | esac 499 | done 500 | } 501 | 502 | _opts_pagination() { 503 | # Extract common pagination keyword options and assign to vars 504 | # 505 | # Usage: 506 | # 507 | # local _follow_next 508 | # _opts_pagination "$@" 509 | 510 | for arg in "$@"; do 511 | case $arg in 512 | (_follow_next=*) _follow_next="${arg#*=}";; 513 | (_follow_next_limit=*) _follow_next_limit="${arg#*=}";; 514 | esac 515 | done 516 | } 517 | 518 | _opts_qs() { 519 | # Format a querystring to append to an URL or a blank string 520 | # 521 | # Usage: 522 | # 523 | # local qs 524 | # _opts_qs "$@" 525 | # _get "/some/path${qs}" 526 | 527 | local querystring=$(_format_urlencode "$@") 528 | qs="${querystring:+?$querystring}" 529 | } 530 | 531 | _request() { 532 | # A wrapper around making HTTP requests with curl 533 | # 534 | # Usage: 535 | # ``` 536 | # _request /repos/:owner/:repo/issues 537 | # printf '{"title": "%s", "body": "%s"}\n' "Stuff" "Things" \ 538 | # | _request /repos/:owner/:repo/issues | jq -r '.[url]' 539 | # printf '{"title": "%s", "body": "%s"}\n' "Stuff" "Things" \ 540 | # | _request /repos/:owner/:repo/issues method=PUT | jq -r '.[url]' 541 | # ``` 542 | # 543 | # Input 544 | # 545 | # * (stdin) 546 | # Data that will be used as the request body. 547 | # 548 | # Positional arguments 549 | # 550 | local path="${1:?Path is required.}" 551 | # The URL path for the HTTP request. 552 | # Must be an absolute path that starts with a `/` or a full URL that 553 | # starts with http(s). Absolute paths will be append to the value in 554 | # `$OK_SH_URL`. 555 | # 556 | # Keyword arguments 557 | # 558 | local method='GET' 559 | # The method to use for the HTTP request. 560 | local content_type='application/json' 561 | # The value of the Content-Type header to use for the request. 562 | 563 | shift 1 564 | 565 | local cmd 566 | local arg 567 | local has_stdin 568 | local trace_curl 569 | 570 | case $path in 571 | (http*) : ;; 572 | *) path="${OK_SH_URL}${path}" ;; 573 | esac 574 | 575 | for arg in "$@"; do 576 | case $arg in 577 | (method=*) method="${arg#*=}";; 578 | (content_type=*) content_type="${arg#*=}";; 579 | esac 580 | done 581 | 582 | case "$method" in 583 | POST | PUT | PATCH) has_stdin=1;; 584 | esac 585 | 586 | [ $OK_SH_VERBOSE -eq 3 ] && trace_curl=1 587 | 588 | [ "$OK_SH_VERBOSE" -eq 1 ] && set -x 589 | curl -nsSi \ 590 | -H "Accept: ${OK_SH_ACCEPT}" \ 591 | -H "Content-Type: ${content_type}" \ 592 | ${has_stdin:+--data-binary @-} \ 593 | ${trace_curl:+--trace-ascii /dev/stderr} \ 594 | -X "${method}" \ 595 | "${path}" 596 | set +x 597 | } 598 | 599 | _response() { 600 | # Process an HTTP response from curl 601 | # 602 | # Output only headers of interest followed by the response body. Additional 603 | # processing is performed on select headers to make them easier to work 604 | # with in sh. See below. 605 | # 606 | # Usage: 607 | # ``` 608 | # _request /some/path | _response status_code ETag Link_next 609 | # curl -isS example.com/some/path | _response status_code status_text | { 610 | # local status_code status_text 611 | # read -r status_code 612 | # read -r status_text 613 | # } 614 | # ``` 615 | # 616 | # Header reformatting 617 | # 618 | # * HTTP Status 619 | # The HTTP line is split into `http_version`, `status_code`, and 620 | # `status_text` variables. 621 | # * ETag 622 | # The surrounding quotes are removed. 623 | # * Link 624 | # Each URL in the Link header is expanded with the URL type appended to 625 | # the name. E.g., `Link_first`, `Link_last`, `Link_next`. 626 | # 627 | # Positional arguments 628 | # 629 | # * $1 - $9 630 | # Each positional arg is the name of an HTTP header. Each header value is 631 | # output in the same order as each argument; each on a single line. A 632 | # blank line is output for headers that cannot be found. 633 | 634 | local hdr 635 | local val 636 | local http_version 637 | local status_code 638 | local status_text 639 | local headers output 640 | 641 | _log debug 'Processing response.' 642 | 643 | read -r http_version status_code status_text 644 | status_text="${status_text% }" 645 | http_version="${http_version#HTTP/}" 646 | 647 | _log debug "Response status is: ${status_code} ${status_text}" 648 | 649 | headers="http_version: ${http_version} 650 | status_code: ${status_code} 651 | status_text: ${status_text} 652 | " 653 | while IFS=": " read -r hdr val; do 654 | # Headers stop at the first blank line. 655 | [ "$hdr" = " " ] && break 656 | val="${val% }" 657 | 658 | # Process each header; reformat some to work better with sh tools. 659 | case "$hdr" in 660 | # Update the GitHub rate limit trackers. 661 | X-RateLimit-Remaining) 662 | printf 'GitHub remaining requests: %s\n' "$val" 1>&$LSUMMARY ;; 663 | X-RateLimit-Reset) 664 | awk -v gh_reset="$val" 'BEGIN { 665 | srand(); curtime = srand() 666 | print "GitHub seconds to reset: " gh_reset - curtime 667 | }' 1>&$LSUMMARY ;; 668 | 669 | # Remove quotes from the etag header. 670 | ETag) val="${val#\"}"; val="${val%\"}" ;; 671 | 672 | # Split the URLs in the Link header into separate pseudo-headers. 673 | Link) headers="${headers}$(printf '%s' "$val" | awk ' 674 | BEGIN { RS=", "; FS="; "; OFS=": " } 675 | { 676 | sub(/^rel="/, "", $2); sub(/"$/, "", $2) 677 | sub(/^$/, "", $1) 678 | print "Link_" $2, $1 679 | }') 680 | " # need trailing newline 681 | ;; 682 | esac 683 | 684 | headers="${headers}${hdr}: ${val} 685 | " # need trailing newline 686 | 687 | done 688 | 689 | # Output requested headers in deterministic order. 690 | for arg in "$@"; do 691 | _log debug "Outputting requested header '${arg}'." 692 | output=$(printf '%s' "$headers" | while IFS=": " read -r hdr val; do 693 | [ "$hdr" = "$arg" ] && printf '%s' "$val" 694 | done) 695 | printf '%s\n' "$output" 696 | done 697 | 698 | # Output the response body. 699 | cat 700 | } 701 | 702 | _get() { 703 | # A wrapper around _request() for common GET patterns 704 | # 705 | # Will automatically follow 'next' pagination URLs in the Link header. 706 | # 707 | # Usage: 708 | # 709 | # _get /some/path 710 | # _get /some/path _follow_next=0 711 | # _get /some/path _follow_next_limit=200 | jq -c . 712 | # 713 | # Positional arguments 714 | # 715 | local path="${1:?Path is required.}" 716 | # The HTTP path or URL to pass to _request(). 717 | # 718 | # Keyword arguments 719 | # 720 | # _follow_next=1 721 | # Automatically look for a 'Links' header and follow any 'next' URLs. 722 | # _follow_next_limit=50 723 | # Maximum number of 'next' URLs to follow before stopping. 724 | 725 | shift 1 726 | local status_code 727 | local status_text 728 | local next_url 729 | 730 | # If the variable is unset or empty set it to a default value. Functions 731 | # that call this function can pass these parameters in one of two ways: 732 | # explicitly as a keyword arg or implicity by setting variables of the same 733 | # names within the local scope. 734 | # shellcheck disable=SC2086 735 | if [ -z ${_follow_next+x} ] || [ -z "${_follow_next}" ]; then 736 | local _follow_next=1 737 | fi 738 | # shellcheck disable=SC2086 739 | if [ -z ${_follow_next_limit+x} ] || [ -z "${_follow_next_limit}" ]; then 740 | local _follow_next_limit=50 741 | fi 742 | 743 | _opts_pagination "$@" 744 | 745 | _request "$path" | _response status_code status_text Link_next | { 746 | read -r status_code 747 | read -r status_text 748 | read -r next_url 749 | 750 | case "$status_code" in 751 | 20*) : ;; 752 | 4*) printf 'Client Error: %s %s\n' \ 753 | "$status_code" "$status_text" 1>&2; exit 1 ;; 754 | 5*) printf 'Server Error: %s %s\n' \ 755 | "$status_code" "$status_text" 1>&2; exit 1 ;; 756 | esac 757 | 758 | # Output response body. 759 | cat 760 | 761 | [ "$_follow_next" -eq 1 ] || return 762 | 763 | _log info "Remaining next link follows: ${_follow_next_limit}" 764 | if [ -n "$next_url" ] && [ $_follow_next_limit -gt 0 ] ; then 765 | _follow_next_limit=$(( _follow_next_limit - 1 )) 766 | 767 | _get "$next_url" "_follow_next_limit=${_follow_next_limit}" 768 | fi 769 | } 770 | } 771 | 772 | _post() { 773 | # A wrapper around _request() for commoon POST / PUT patterns 774 | # 775 | # Usage: 776 | # 777 | # _format_json foo=Foo bar=Bar | _post /some/path 778 | # _format_json foo=Foo bar=Bar | _post /some/path method='PUT' 779 | # _post /some/path filename=somearchive.tar 780 | # _post /some/path filename=somearchive.tar mime_type=application/x-tar 781 | # _post /some/path filename=somearchive.tar \ 782 | # mime_type=$(file -b --mime-type somearchive.tar) 783 | # 784 | # Input 785 | # 786 | # * (stdin) 787 | # Optional. See the `filename` argument also. 788 | # Data that will be used as the request body. 789 | # 790 | # Positional arguments 791 | # 792 | local path="${1:?Path is required.}" 793 | # The HTTP path or URL to pass to _request(). 794 | # 795 | # Keyword arguments 796 | # 797 | local method='POST' 798 | # The method to use for the HTTP request. 799 | local filename 800 | # Optional. See the `stdin` option above also. 801 | # Takes precedence over any data passed as stdin and loads a file off the 802 | # file system to serve as the request body. 803 | local mime_type 804 | # The value of the Content-Type header to use for the request. 805 | # If the `filename` argument is given this value will be guessed from the 806 | # file extension. If the `filename` argument is not given (i.e., using 807 | # stdin) this value defaults to `application/json`. Specifying this 808 | # argument overrides all other defaults or guesses. 809 | 810 | shift 1 811 | 812 | for arg in "$@"; do 813 | case $arg in 814 | (method=*) method="${arg#*=}";; 815 | (filename=*) filename="${arg#*=}";; 816 | (mime_type=*) mime_type="${arg#*=}";; 817 | esac 818 | done 819 | 820 | # Make either the file or stdin available as fd7. 821 | if [ -n "$filename" ] ; then 822 | if [ -r "$filename" ] ; then 823 | _log debug "Using '${filename}' as POST data." 824 | [ -n "$mime_type" ] || _get_mime_type "$filename" 825 | : ${mime_type:?The MIME type could not be guessed.} 826 | exec 7<"$filename" 827 | else 828 | printf 'File could not be found or read.\n' 1>&2 829 | exit 1 830 | fi 831 | else 832 | _log debug "Using stdin as POST data." 833 | mime_type='application/json' 834 | exec 7<&0 835 | fi 836 | 837 | _request "$path" method="$method" content_type="$mime_type" 0<&7 \ 838 | | _response status_code status_text \ 839 | | { 840 | read -r status_code 841 | read -r status_text 842 | 843 | case "$status_code" in 844 | 20*) : ;; 845 | 4*) printf 'Client Error: %s %s\n' \ 846 | "$status_code" "$status_text" 1>&2; exit 1 ;; 847 | 5*) printf 'Server Error: %s %s\n' \ 848 | "$status_code" "$status_text" 1>&2; exit 1 ;; 849 | esac 850 | 851 | # Output response body. 852 | cat 853 | } 854 | } 855 | 856 | _delete() { 857 | # A wrapper around _request() for common DELETE patterns 858 | # 859 | # Usage: 860 | # 861 | # _delete '/some/url' 862 | # 863 | # Return: 0 for success; 1 for failure. 864 | # 865 | # Positional arguments 866 | # 867 | local url="${1:?URL is required.}" 868 | # The URL to send the DELETE request to. 869 | 870 | local status_code 871 | 872 | _request "${url}" method='DELETE' | _response status_code | { 873 | read -r status_code 874 | [ "$status_code" = "204" ] 875 | exit $? 876 | } 877 | } 878 | 879 | # ## GitHub 880 | # Friendly functions for common GitHub tasks. 881 | 882 | # ### Authorization 883 | # Perform authentication and authorization. 884 | 885 | show_scopes() { 886 | # Show the permission scopes for the currently authenticated user 887 | # 888 | # Usage: 889 | # 890 | # show_scopes 891 | 892 | local oauth_scopes 893 | 894 | _request '/' | _response X-OAuth-Scopes | { 895 | read -r oauth_scopes 896 | 897 | printf '%s\n' "$oauth_scopes" 898 | 899 | # Dump any remaining response body. 900 | cat >/dev/null 901 | } 902 | } 903 | 904 | # ### Repository 905 | # Create, update, delete, list repositories. 906 | 907 | org_repos() { 908 | # List organization repositories 909 | # 910 | # Usage: 911 | # 912 | # org_repos myorg 913 | # org_repos myorg type=private per_page=10 914 | # org_repos myorg _filter='.[] | "\(.name)\t\(.owner.login)"' 915 | # 916 | # Positional arguments 917 | # 918 | local org="${1:?Org name required.}" 919 | # Organization GitHub login or id for which to list repos. 920 | # 921 | # Keyword arguments 922 | # 923 | local _follow_next 924 | # Automatically look for a 'Links' header and follow any 'next' URLs. 925 | local _follow_next_limit 926 | # Maximum number of 'next' URLs to follow before stopping. 927 | local _filter='.[] | "\(.name)\t\(.ssh_url)"' 928 | # A jq filter to apply to the return data. 929 | # 930 | # Querystring arguments may also be passed as keyword arguments: 931 | # per_page, type 932 | 933 | shift 1 934 | local qs 935 | 936 | _opts_pagination "$@" 937 | _opts_filter "$@" 938 | _opts_qs "$@" 939 | 940 | _get "/orgs/${org}/repos${qs}" | _filter_json "${_filter}" 941 | } 942 | 943 | org_teams() { 944 | # List teams 945 | # 946 | # Usage: 947 | # 948 | # org_teams org 949 | # 950 | # Positional arguments 951 | # 952 | local org="${1:?Org name required.}" 953 | # Organization GitHub login or id. 954 | # 955 | # Keyword arguments 956 | # 957 | local _filter='.[] | "\(.name)\t\(.id)\t\(.permission)"' 958 | # A jq filter to apply to the return data. 959 | 960 | shift 1 961 | 962 | _opts_filter "$@" 963 | 964 | _get "/orgs/${org}/teams" \ 965 | | _filter_json "${_filter}" 966 | } 967 | 968 | list_repos() { 969 | # List user repositories 970 | # 971 | # Usage: 972 | # 973 | # list_repos 974 | # list_repos user 975 | # 976 | # Positional arguments 977 | # 978 | local user="$1" 979 | # Optional GitHub user login or id for which to list repos. 980 | # 981 | # Keyword arguments 982 | # 983 | local _filter='.[] | "\(.name)\t\(.html_url)"' 984 | # A jq filter to apply to the return data. 985 | # 986 | # Querystring arguments may also be passed as keyword arguments: 987 | # per_page, type, sort, direction 988 | 989 | shift 1 990 | local qs 991 | 992 | _opts_filter "$@" 993 | _opts_qs "$@" 994 | 995 | if [ -n "$user" ] ; then 996 | url="/users/${user}/repos" 997 | else 998 | url='/user/repos' 999 | fi 1000 | 1001 | _get "${url}${qs}" | _filter_json "${_filter}" 1002 | } 1003 | 1004 | create_repo() { 1005 | # Create a repository for a user or organization 1006 | # 1007 | # Usage: 1008 | # 1009 | # create_repo foo 1010 | # create_repo bar description='Stuff and things' homepage='example.com' 1011 | # create_repo baz organization=myorg 1012 | # 1013 | # Positional arguments 1014 | # 1015 | local name="${1:?Repo name required.}" 1016 | # Name of the new repo 1017 | # 1018 | # Keyword arguments 1019 | # 1020 | local _filter='.[] | "\(.name)\t\(.html_url)"' 1021 | # A jq filter to apply to the return data. 1022 | # 1023 | # POST data may also be passed as keyword arguments: 1024 | # description, homepage, private, has_issues, has_wiki, has_downloads, 1025 | # organization, team_id, auto_init, gitignore_template 1026 | 1027 | shift 1 1028 | 1029 | _opts_filter "$@" 1030 | 1031 | local url 1032 | local organization 1033 | 1034 | for arg in "$@"; do 1035 | case $arg in 1036 | (organization=*) organization="${arg#*=}";; 1037 | esac 1038 | done 1039 | 1040 | if [ -n "$organization" ] ; then 1041 | url="/orgs/${organization}/repos" 1042 | else 1043 | url='/user/repos' 1044 | fi 1045 | 1046 | _format_json "name=${name}" "$@" | _post "$url" | _filter_json "${_filter}" 1047 | } 1048 | 1049 | delete_repo() { 1050 | # Create a repository for a user or organization 1051 | # 1052 | # Usage: 1053 | # 1054 | # delete_repo owner repo 1055 | # 1056 | # The currently authenticated user must have the `delete_repo` scope. View 1057 | # current scopes with the `show_scopes()` function. 1058 | # 1059 | # Positional arguments 1060 | # 1061 | local owner="${1:?Owner name required.}" 1062 | # Name of the new repo 1063 | local repo="${2:?Repo name required.}" 1064 | # Name of the new repo 1065 | 1066 | shift 2 1067 | 1068 | local confirm 1069 | 1070 | _get_confirm 'This will permanently delete a repository! Continue?' 1071 | [ "$confirm" -eq 1 ] || exit 0 1072 | 1073 | _delete "/repos/${owner}/${repo}" 1074 | exit $? 1075 | } 1076 | 1077 | # ### Releases 1078 | # Create, update, delete, list releases. 1079 | 1080 | list_releases() { 1081 | # List releases for a repository 1082 | # 1083 | # Usage: 1084 | # 1085 | # list_releases org repo '\(.assets[0].name)\t\(.name.id)' 1086 | # 1087 | # Positional arguments 1088 | # 1089 | local owner="${1:?Owner name required.}" 1090 | # A GitHub user or organization. 1091 | local repo="${2:?Repo name required.}" 1092 | # A GitHub repository. 1093 | # 1094 | # Keyword arguments 1095 | # 1096 | local _filter='.[] | "\(.name)\t\(.id)\t\(.html_url)"' 1097 | # A jq filter to apply to the return data. 1098 | 1099 | shift 2 1100 | 1101 | _opts_filter "$@" 1102 | 1103 | _get "/repos/${owner}/${repo}/releases" \ 1104 | | _filter_json "${_filter}" 1105 | } 1106 | 1107 | release() { 1108 | # Get a release 1109 | # 1110 | # Usage: 1111 | # 1112 | # release user repo 1087855 1113 | # 1114 | # Positional arguments 1115 | # 1116 | local owner="${1:?Owner name required.}" 1117 | # A GitHub user or organization. 1118 | local repo="${2:?Repo name required.}" 1119 | # A GitHub repository. 1120 | local release_id="${3:?Release ID required.}" 1121 | # The unique ID of the release; see list_releases. 1122 | # 1123 | # Keyword arguments 1124 | # 1125 | local _filter='"\(.author.login)\t\(.published_at)"' 1126 | # A jq filter to apply to the return data. 1127 | 1128 | shift 3 1129 | 1130 | _opts_filter "$@" 1131 | 1132 | _get "/repos/${owner}/${repo}/releases/${release_id}" \ 1133 | | _filter_json "${_filter}" 1134 | } 1135 | 1136 | create_release() { 1137 | # Create a release 1138 | # 1139 | # Usage: 1140 | # 1141 | # create_release org repo v1.2.3 1142 | # create_release user repo v3.2.1 draft=true 1143 | # 1144 | # Positional arguments 1145 | # 1146 | local owner="${1:?Owner name required.}" 1147 | # A GitHub user or organization. 1148 | local repo="${2:?Repo name required.}" 1149 | # A GitHub repository. 1150 | local tag_name="${3:?Tag name required.}" 1151 | # Git tag from which to create release. 1152 | # 1153 | # Keyword arguments 1154 | # 1155 | local _filter='"\(.name)\t\(.id)\t\(.html_url)"' 1156 | # A jq filter to apply to the return data. 1157 | # 1158 | # POST data may also be passed as keyword arguments: 1159 | # body, draft, name, prerelease, target_commitish 1160 | 1161 | shift 3 1162 | 1163 | _opts_filter "$@" 1164 | 1165 | _format_json "tag_name=${tag_name}" "$@" \ 1166 | | _post "/repos/${owner}/${repo}/releases" \ 1167 | | _filter_json "${_filter}" 1168 | } 1169 | 1170 | delete_release() { 1171 | # Delete a release 1172 | # 1173 | # Usage: 1174 | # 1175 | # delete_release org repo 1087855 1176 | # 1177 | # Return: 0 for success; 1 for failure. 1178 | # 1179 | # Positional arguments 1180 | # 1181 | local owner="${1:?Owner name required.}" 1182 | # A GitHub user or organization. 1183 | local repo="${2:?Repo name required.}" 1184 | # A GitHub repository. 1185 | local release_id="${3:?Release ID required.}" 1186 | # The unique ID of the release; see list_releases. 1187 | 1188 | shift 3 1189 | 1190 | local confirm 1191 | 1192 | _get_confirm 'This will permanently delete a release. Continue?' 1193 | [ "$confirm" -eq 1 ] || exit 0 1194 | 1195 | _delete "/repos/${owner}/${repo}/releases/${release_id}" 1196 | exit $? 1197 | } 1198 | 1199 | release_assets() { 1200 | # List release assets 1201 | # 1202 | # Usage: 1203 | # 1204 | # release_assets user repo 1087855 1205 | # 1206 | # Positional arguments 1207 | # 1208 | local owner="${1:?Owner name required.}" 1209 | # A GitHub user or organization. 1210 | local repo="${2:?Repo name required.}" 1211 | # A GitHub repository. 1212 | local release_id="${3:?Release ID required.}" 1213 | # The unique ID of the release; see list_releases. 1214 | # 1215 | # Keyword arguments 1216 | # 1217 | local _filter='.[] | "\(.id)\t\(.name)\t\(.updated_at)"' 1218 | # A jq filter to apply to the return data. 1219 | 1220 | shift 3 1221 | 1222 | _opts_filter "$@" 1223 | 1224 | _get "/repos/${owner}/${repo}/releases/${release_id}/assets" \ 1225 | | _filter_json "$_filter" 1226 | } 1227 | 1228 | upload_asset() { 1229 | # Upload a release asset 1230 | # 1231 | # Note, this command requires `jq` to find the release `upload_url`. 1232 | # 1233 | # Usage: 1234 | # 1235 | # upload_asset username reponame 1087938 \ 1236 | # foo.tar application/x-tar < foo.tar 1237 | # 1238 | # * (stdin) 1239 | # The contents of the file to upload. 1240 | # 1241 | # Positional arguments 1242 | # 1243 | local owner="${1:?Owner name required.}" 1244 | # A GitHub user or organization. 1245 | local repo="${2:?Repo name required.}" 1246 | # A GitHub repository. 1247 | local release_id="${3:?Release ID required.}" 1248 | # The unique ID of the release; see list_releases. 1249 | local name="${4:?File name is required.}" 1250 | # The file name of the asset. 1251 | # 1252 | # Keyword arguments 1253 | # 1254 | local _filter='"\(.state)\t\(.browser_download_url)"' 1255 | # A jq filter to apply to the return data. 1256 | 1257 | shift 4 1258 | 1259 | _opts_filter "$@" 1260 | 1261 | local upload_url=$(release "$owner" "$repo" "$release_id" \ 1262 | 'filter="\(.upload_url)"' | sed -e 's/{?name}/?name='"$name"'/g') 1263 | 1264 | : "${upload_url:?Upload URL could not be retrieved.}" 1265 | 1266 | _post "$upload_url" filename="$name" \ 1267 | | _filter_json "$_filter" 1268 | } 1269 | 1270 | # ### Issues 1271 | # Create, update, edit, delete, list issues and milestones. 1272 | 1273 | list_milestones() { 1274 | # List milestones for a repository 1275 | # 1276 | # Usage: 1277 | # 1278 | # list_milestones someuser/somerepo 1279 | # list_milestones someuser/somerepo state=closed 1280 | # 1281 | # Positional arguments 1282 | # 1283 | local repository="${1:?Repo name required.}" 1284 | # A GitHub repository. 1285 | # 1286 | # Keyword arguments 1287 | # 1288 | local _follow_next 1289 | # Automatically look for a 'Links' header and follow any 'next' URLs. 1290 | local _follow_next_limit 1291 | # Maximum number of 'next' URLs to follow before stopping. 1292 | local _filter='.[] | "\(.number)\t\(.open_issues)/\(.closed_issues)\t\(.title)"' 1293 | # A jq filter to apply to the return data. 1294 | # 1295 | # GitHub querystring arguments may also be passed as keyword arguments: 1296 | # per_page, state, sort, direction 1297 | 1298 | shift 1 1299 | local qs 1300 | 1301 | _opts_pagination "$@" 1302 | _opts_filter "$@" 1303 | _opts_qs "$@" 1304 | 1305 | _get "/repos/${repository}/milestones${qs}" | _filter_json "$_filter" 1306 | } 1307 | 1308 | create_milestone() { 1309 | # Create a milestone for a repository 1310 | # 1311 | # Usage: 1312 | # 1313 | # create_milestone someuser/somerepo MyMilestone 1314 | # 1315 | # create_milestone someuser/somerepo MyMilestone \ 1316 | # due_on=2015-06-16T16:54:00Z \ 1317 | # description='Long description here 1318 | # that spans multiple lines.' 1319 | # 1320 | # Positional arguments 1321 | # 1322 | local repo="${1:?Repo name required.}" 1323 | # A GitHub repository. 1324 | local title="${2:?Milestone name required.}" 1325 | # A unique title. 1326 | # 1327 | # Keyword arguments 1328 | # 1329 | local _filter='"\(.number)\t\(.html_url)"' 1330 | # A jq filter to apply to the return data. 1331 | # 1332 | # Milestone options may also be passed as keyword arguments: 1333 | # state, description, due_on 1334 | 1335 | shift 2 1336 | 1337 | _opts_filter "$@" 1338 | 1339 | _format_json title="$title" "$@" \ 1340 | | _post "/repos/${repo}/milestones" \ 1341 | | _filter_json "$_filter" 1342 | } 1343 | 1344 | list_issues() { 1345 | # List issues for the authenticated user or repository 1346 | # 1347 | # Usage: 1348 | # 1349 | # list_issues 1350 | # list_issues someuser/somerepo 1351 | # list_issues someuser/somerepo state=closed labels=foo,bar 1352 | # 1353 | # Positional arguments 1354 | # 1355 | local repository="$1" 1356 | # A GitHub repository. 1357 | # 1358 | # Keyword arguments 1359 | # 1360 | local _follow_next 1361 | # Automatically look for a 'Links' header and follow any 'next' URLs. 1362 | local _follow_next_limit 1363 | # Maximum number of 'next' URLs to follow before stopping. 1364 | local _filter='.[] | "\(.number)\t\(.title)"' 1365 | # A jq filter to apply to the return data. 1366 | # 1367 | # GitHub querystring arguments may also be passed as keyword arguments: 1368 | # per_page, milestone, state, assignee, creator, mentioned, labels, sort, 1369 | # direction, since 1370 | 1371 | shift 1 1372 | local url 1373 | local qs 1374 | 1375 | _opts_pagination "$@" 1376 | _opts_filter "$@" 1377 | _opts_qs "$@" 1378 | 1379 | if [ -n "$repository" ] ; then 1380 | url="/repos/${repository}/issues" 1381 | else 1382 | url='/user/issues' 1383 | fi 1384 | 1385 | _get "${url}${qs}" | _filter_json "$_filter" 1386 | } 1387 | 1388 | user_issues() { 1389 | # List all issues across owned and member repositories for the authenticated user 1390 | # 1391 | # Usage: 1392 | # 1393 | # user_issues 1394 | # user_issues since=2015-60-11T00:09:00Z 1395 | # 1396 | # Positional arguments 1397 | # 1398 | local repository="$1" 1399 | # A GitHub repository. 1400 | # 1401 | # Keyword arguments 1402 | # 1403 | local _follow_next 1404 | # Automatically look for a 'Links' header and follow any 'next' URLs. 1405 | local _follow_next_limit 1406 | # Maximum number of 'next' URLs to follow before stopping. 1407 | local _filter='.[] | "\(.number)\t\(.title)"' 1408 | # A jq filter to apply to the return data. 1409 | # 1410 | # GitHub querystring arguments may also be passed as keyword arguments: 1411 | # per_page, filter, state, labels, sort, direction, since 1412 | 1413 | shift 1 1414 | local qs 1415 | 1416 | _opts_pagination "$@" 1417 | _opts_filter "$@" 1418 | _opts_qs "$@" 1419 | 1420 | _get "/issues${qs}" | _filter_json "$_filter" 1421 | } 1422 | 1423 | org_issues() { 1424 | # List all issues for a given organization for the authenticated user 1425 | # 1426 | # Usage: 1427 | # 1428 | # org_issues someorg 1429 | # 1430 | # Positional arguments 1431 | # 1432 | local org="${1:?Organization name required.}" 1433 | # Organization GitHub login or id. 1434 | # 1435 | # Keyword arguments 1436 | # 1437 | local _follow_next 1438 | # Automatically look for a 'Links' header and follow any 'next' URLs. 1439 | local _follow_next_limit 1440 | # Maximum number of 'next' URLs to follow before stopping. 1441 | local _filter='.[] | "\(.number)\t\(.title)"' 1442 | # A jq filter to apply to the return data. 1443 | # 1444 | # GitHub querystring arguments may also be passed as keyword arguments: 1445 | # per_page, filter, state, labels, sort, direction, since 1446 | 1447 | shift 1 1448 | local qs 1449 | 1450 | _opts_pagination "$@" 1451 | _opts_filter "$@" 1452 | _opts_qs "$@" 1453 | 1454 | _get "/orgs/${org}/issues${qs}" | _filter_json "$_filter" 1455 | } 1456 | 1457 | labels() { 1458 | # List available labels for a repository 1459 | # 1460 | # Usage: 1461 | # 1462 | # labels someuser/somerepo 1463 | # 1464 | # Positional arguments 1465 | # 1466 | local repo="$1" 1467 | # A GitHub repository. 1468 | # 1469 | # Keyword arguments 1470 | # 1471 | local _follow_next 1472 | # Automatically look for a 'Links' header and follow any 'next' URLs. 1473 | local _follow_next_limit 1474 | # Maximum number of 'next' URLs to follow before stopping. 1475 | local _filter='.[] | "\(.name)\t\(.color)"' 1476 | # A jq filter to apply to the return data. 1477 | 1478 | _opts_pagination "$@" 1479 | _opts_filter "$@" 1480 | 1481 | _get "/repos/${repo}/labels" | _filter_json "$_filter" 1482 | } 1483 | 1484 | add_label() { 1485 | # Add a label to a repository 1486 | # 1487 | # Usage: 1488 | # add_label someuser/somereapo LabelName color 1489 | # 1490 | # Positional arguments 1491 | # 1492 | local repo="${1:?Repo name required.}" 1493 | # A GitHub repository. 1494 | local label="${2:?Label name required.}" 1495 | # A new label. 1496 | local color="${3:?Hex color required.}" 1497 | # A color, in hex, without the leading `#`. 1498 | # 1499 | # Keyword arguments 1500 | # 1501 | local _filter='"\(.name)\t\(.color)"' 1502 | # A jq filter to apply to the return data. 1503 | 1504 | _opts_filter "$@" 1505 | 1506 | _format_json name="$label" color="$color" \ 1507 | | _post "/repos/${repo}/labels" \ 1508 | | _filter_json "$_filter" 1509 | } 1510 | 1511 | update_label() { 1512 | # Update a label 1513 | # 1514 | # Usage: 1515 | # update_label someuser/somereapo OldLabelName \ 1516 | # label=NewLabel color=newcolor 1517 | # 1518 | # Positional arguments 1519 | # 1520 | local repo="${1:?Repo name required.}" 1521 | # A GitHub repository. 1522 | local label="${2:?Label name required.}" 1523 | # The name of the label which will be updated 1524 | # 1525 | # Keyword arguments 1526 | # 1527 | local _filter='"\(.name)\t\(.color)"' 1528 | # A jq filter to apply to the return data. 1529 | # 1530 | # Label options may also be passed as keyword arguments, these will update 1531 | # the existing values: 1532 | # name, color 1533 | 1534 | shift 2 1535 | 1536 | _opts_filter "$@" 1537 | 1538 | _format_json "$@" \ 1539 | | _post "/repos/${repo}/labels/${label}" method='PATCH' \ 1540 | | _filter_json "$_filter" 1541 | } 1542 | 1543 | add_team_repo() { 1544 | # Add a team repository 1545 | # 1546 | # Usage: 1547 | # 1548 | # add_team_repo team_id organization repository_name permission 1549 | # 1550 | # Positional arguments 1551 | # 1552 | local team_id="${1:?Team id required.}" 1553 | # Team id to add repository to 1554 | local organization="${2:?Organization required.}" 1555 | # Organization to add repository to 1556 | local repository_name="${3:?Repository name required.}" 1557 | # Repository name to add 1558 | local permission="${4:?Permission required.}" 1559 | # Permission to grant: pull, push, admin 1560 | # 1561 | local url="/teams/${team_id}/repos/${organization}/${repository_name}" 1562 | 1563 | export OK_SH_ACCEPT="application/vnd.github.ironman-preview+json" 1564 | 1565 | _format_json "name=${name}" "permission=${permission}" | _post "$url" method='PUT' | _filter_json "${_filter}" 1566 | } 1567 | 1568 | __main "$@" 1569 | --------------------------------------------------------------------------------