From d9f16281ac1fb59ca11d2be3a5a58bef780bd7bd Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 20:35:50 -0400 Subject: [PATCH 01/20] Use repo:+is:issue query to get exact issue count --- examples/sgh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/sgh b/examples/sgh index 1427322..6e5b75d 100755 --- a/examples/sgh +++ b/examples/sgh @@ -63,11 +63,10 @@ issue_list_cmd() { issue_list() { _resolve_repo _get_auth_token - _update_query per_page "100" - _gh_api_query "repos/$REPO/issues" | \ - jq -M "map(select(has(\"pull_request\")|not) | - \"\(.number): \(.title)\") | - .[:$NUMBER] | .[]" \ + _update_query q "repo:$REPO+is:issue" + _update_query per_page "$NUMBER" + _gh_api_query "search/issues" | \ + jq -M '.items[] | "\(.number): \(.title)"' \ | xargs -I{} echo {} } From c80c27ab8de38bceab7ef862aca0f770de9dbd28 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 20:38:35 -0400 Subject: [PATCH 02/20] The all state is not "real" --- examples/sgh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/sgh b/examples/sgh index 6e5b75d..71b882e 100755 --- a/examples/sgh +++ b/examples/sgh @@ -112,10 +112,12 @@ pr_list_cmd() { pr_list() { _resolve_repo _get_auth_token - _update_query state "$STATE" - _update_query per_page $NUMBER - _gh_api_query "repos/$REPO/pulls" | \ - jq -M '.[] | "\(.number): \(.title)"' | xargs -I{} echo {} + q_val="repo:$REPO+is:pr" + [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" + _update_query q "$q_val" + _update_query per_page "$NUMBER" + _gh_api_query "search/issues" | \ + jq -M '.items[] | "\(.number): \(.title)"' | xargs -I{} echo {} } pr_get_cmd() { @@ -170,7 +172,7 @@ _gh_api() { [ -n "$auth_token" ] && curl_call="$curl_call \\ -H \"Authorization: Bearer $auth_token\"" curl_call="$curl_call \\ - https://api.github.com/$1" + 'https://api.github.com/$1'" eval "$curl_call" } From 6d19f63c99735910805fc45dce4e125bc9152e36 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 20:40:17 -0400 Subject: [PATCH 03/20] Add state filter to issue list --- examples/sgh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/sgh b/examples/sgh index 71b882e..d526ce2 100755 --- a/examples/sgh +++ b/examples/sgh @@ -57,13 +57,16 @@ issue_list_cmd() { cmd_func issue_list cmd_help "List issues in a GitHub repository" + cmd_optd -s --state -- STATE open "State of issue: open, closed, all" cmd_optd -n --number -- NUMBER 10 "Max number of results" } issue_list() { _resolve_repo _get_auth_token - _update_query q "repo:$REPO+is:issue" + q_val="repo:$REPO+is:issue" + [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" + _update_query q "$q_val" _update_query per_page "$NUMBER" _gh_api_query "search/issues" | \ jq -M '.items[] | "\(.number): \(.title)"' \ From 74259eb2ff7e7607ab7773b81e1922e2a564f74b Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 20:48:45 -0400 Subject: [PATCH 04/20] Rename get -> view like in gh --- examples/sgh | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/sgh b/examples/sgh index d526ce2..f7a340a 100755 --- a/examples/sgh +++ b/examples/sgh @@ -46,7 +46,7 @@ auth_clear() { issue_cmd() { cmd_name issue - cmd_subs issue_list_cmd issue_get_cmd + cmd_subs issue_list_cmd issue_view_cmd cmd_help "Commands for interacting with github repository issues" cmd_optd :defer: -R --repo -- REPO "current repo" "Github repository in OWNER/REPO format" @@ -73,15 +73,15 @@ issue_list() { | xargs -I{} echo {} } -issue_get_cmd() { - cmd_name get - cmd_func issue_get - cmd_help "Get issue in a GitHub repository" +issue_view_cmd() { + cmd_name view + cmd_func issue_view + cmd_help "View issue in a GitHub repository" - cmd_argr ISSUE_NUMBER "Issue number to get" + cmd_argr ISSUE_NUMBER "Issue number to view" } -issue_get() { +issue_view() { _resolve_repo _get_auth_token result=$(_gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ @@ -97,7 +97,7 @@ Body: \(.body) pr_cmd() { cmd_name pr - cmd_subs pr_list_cmd pr_get_cmd + cmd_subs pr_list_cmd pr_view_cmd cmd_help "Commands for interacting with github repository pull requests" cmd_optd :defer: -R --repo -- REPO "current repo" "Github repository in OWNER/REPO format" @@ -123,15 +123,15 @@ pr_list() { jq -M '.items[] | "\(.number): \(.title)"' | xargs -I{} echo {} } -pr_get_cmd() { - cmd_name get - cmd_func pr_get - cmd_help "Get pr in a GitHub repository" +pr_view_cmd() { + cmd_name view + cmd_func pr_view + cmd_help "View pull request in a GitHub repository" - cmd_argr PR_NUMBER "Pull request number to get" + cmd_argr PR_NUMBER "Pull request number to view" } -pr_get() { +pr_view() { _resolve_repo _get_auth_token _gh_api_query "repos/$REPO/pulls/$PR_NUMBER" | \ From 0a04ae622640ad48bc707dfb0ba8f9b3c044be5f Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 21:04:17 -0400 Subject: [PATCH 05/20] Start returning the same data as in gh issue list --- examples/sgh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/sgh b/examples/sgh index f7a340a..72db7ba 100755 --- a/examples/sgh +++ b/examples/sgh @@ -69,8 +69,9 @@ issue_list() { _update_query q "$q_val" _update_query per_page "$NUMBER" _gh_api_query "search/issues" | \ - jq -M '.items[] | "\(.number): \(.title)"' \ - | xargs -I{} echo {} + jq -r '.items[] | + "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | + "#\(.number) \(.state) \(.title) \(.user.login) \($date)"' } issue_view_cmd() { From d4fca06a1cc20f8e5f92984dbe49342680cf1f49 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 21:07:19 -0400 Subject: [PATCH 06/20] Same thing for pr list --- examples/sgh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/sgh b/examples/sgh index 72db7ba..9d0acc6 100755 --- a/examples/sgh +++ b/examples/sgh @@ -121,7 +121,9 @@ pr_list() { _update_query q "$q_val" _update_query per_page "$NUMBER" _gh_api_query "search/issues" | \ - jq -M '.items[] | "\(.number): \(.title)"' | xargs -I{} echo {} + jq -r '.items[] | + "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | + "#\(.number) \(.state) \(.title) \(.user.login) \($date)"' } pr_view_cmd() { From ee44c04e250aafb5b3d14cfcfc627237e2628041 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 21:27:22 -0400 Subject: [PATCH 07/20] More elegant auth (error) handling --- examples/sgh | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/examples/sgh b/examples/sgh index 9d0acc6..5b6d732 100755 --- a/examples/sgh +++ b/examples/sgh @@ -5,6 +5,8 @@ # github rest api. Documentation: # https://docs.github.com/en/rest?apiVersion=2022-11-28 +set -o pipefail + . "${0%/*}"/shifu && shifu_less || exit 1 auth_file="$HOME/.sgh" @@ -63,7 +65,6 @@ issue_list_cmd() { issue_list() { _resolve_repo - _get_auth_token q_val="repo:$REPO+is:issue" [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" _update_query q "$q_val" @@ -84,7 +85,6 @@ issue_view_cmd() { issue_view() { _resolve_repo - _get_auth_token result=$(_gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ jq -M ' "\(.title) @@ -115,7 +115,6 @@ pr_list_cmd() { pr_list() { _resolve_repo - _get_auth_token q_val="repo:$REPO+is:pr" [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" _update_query q "$q_val" @@ -136,7 +135,6 @@ pr_view_cmd() { pr_view() { _resolve_repo - _get_auth_token _gh_api_query "repos/$REPO/pulls/$PR_NUMBER" | \ jq -M ' "\(.title) @@ -172,6 +170,7 @@ _gh_api_query() { } _gh_api() { + [ -f "$auth_file" ] && auth_token=$(cat "$auth_file") curl_call="curl -sL \\ -H \"Accept: application/vnd.github+json\" \\ -H \"X-GitHub-Api-Version: 2022-11-28\"" @@ -179,17 +178,13 @@ _gh_api() { -H \"Authorization: Bearer $auth_token\"" curl_call="$curl_call \\ 'https://api.github.com/$1'" - eval "$curl_call" -} - -_get_auth_token() { - [ -f "$auth_file" ] && auth_token=$(cat "$auth_file") - result=$(_gh_api) - jq -e 'has("status") and .status != "200"' <<< "$result" > /dev/null - if [ $? -eq 0 ]; then - echo "$result" | jq -rM '.message' + result=$(eval "$curl_call") + if printf "%s" "$result" | jq -e 'has("message")' > /dev/null 2>&1; then + printf "%s" "$result" | jq -r '.message' >&2 exit 1 fi + printf "%s" "$result" } + shifu_run sgh_cmd "$@" From b38835ed7138f5b4ead3c8b27de778ab8709ad29 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 19 Apr 2026 21:37:22 -0400 Subject: [PATCH 08/20] More accurately match the issue/pr view output format --- examples/sgh | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/examples/sgh b/examples/sgh index 5b6d732..7531ebc 100755 --- a/examples/sgh +++ b/examples/sgh @@ -85,15 +85,15 @@ issue_view_cmd() { issue_view() { _resolve_repo - result=$(_gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ - jq -M ' - "\(.title) -URL: \(.url) -State: \(.state) -Author: \(.user.login) -Body: \(.body) -"') - printf "$result" + _gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ + jq -r '"title:\t\(.title) +state:\t\(.state) +author:\t\(.user.login) +labels:\t\([.labels[].name] | join(", ")) +comments:\t\(.comments) +number:\t\(.number) +-- +\(.body)"' } pr_cmd() { @@ -136,13 +136,15 @@ pr_view_cmd() { pr_view() { _resolve_repo _gh_api_query "repos/$REPO/pulls/$PR_NUMBER" | \ - jq -M ' - "\(.title) -URL: \(.issue_url) -State: \(.state) -Author: \(.user.login) -Body: \(.body) -"' | xargs printf + jq -r '"title:\t\(.title) +state:\t\(if .merged then "merged" else .state end) +author:\t\(.user.login) +number:\t\(.number) +url:\t\(.html_url) +additions:\t\(.additions) +deletions:\t\(.deletions) +-- +\(.body)"' } _resolve_repo() { From f6f44e918cca905c1e569c06bdce7d661c52719f Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sat, 25 Apr 2026 12:03:01 -0400 Subject: [PATCH 09/20] Show list results in crude table --- examples/sgh | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/examples/sgh b/examples/sgh index 7531ebc..5cd066f 100755 --- a/examples/sgh +++ b/examples/sgh @@ -72,7 +72,7 @@ issue_list() { _gh_api_query "search/issues" | \ jq -r '.items[] | "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - "#\(.number) \(.state) \(.title) \(.user.login) \($date)"' + "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table } issue_view_cmd() { @@ -122,7 +122,7 @@ pr_list() { _gh_api_query "search/issues" | \ jq -r '.items[] | "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - "#\(.number) \(.state) \(.title) \(.user.login) \($date)"' + "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table } pr_view_cmd() { @@ -147,6 +147,29 @@ deletions:\t\(.deletions) \(.body)"' } +_table() { + local input widths + input=$(cat) + [ -z "$input" ] && return + widths=$(printf "%s" "$input" | jq -Rrs ' + split("\n") | map(select(length > 0)) + | map(split("\t") | map(length)) + | transpose | map(max) | @tsv') + IFS=$'\t' read -ra w <<< "$widths" + + while IFS=$'\t' read -ra fields; do + last=$((${#fields[@]} - 1)) + for i in "${!fields[@]}"; do + if [ "$i" -lt "$last" ]; then + printf "%s%*s " "${fields[$i]}" "$((w[i] - ${#fields[$i]}))" "" + else + printf "%s" "${fields[$i]}" + fi + done + printf "\n" + done <<< "$input" +} + _resolve_repo() { [ "$REPO" != "current repo" ] && return origin_url=$(git config --get remote.origin.url) From df35c6ca4ec372d9094ab4ef0fd725b701aca900 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sat, 25 Apr 2026 15:08:05 -0400 Subject: [PATCH 10/20] Update doc string --- examples/sgh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sgh b/examples/sgh index 5cd066f..8ca4953 100755 --- a/examples/sgh +++ b/examples/sgh @@ -2,7 +2,7 @@ # Example shifu cli to partially reproduce similar functionality that # the github cli, gh, provides. Wrapper around curl commands to the -# github rest api. Documentation: +# github rest api and jq for response parsing. Documentation: # https://docs.github.com/en/rest?apiVersion=2022-11-28 set -o pipefail From b4def29c9dc16537c12482d82d3041b0f825b6db Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 09:13:25 -0400 Subject: [PATCH 11/20] Organize file intentionally --- examples/sgh | 148 +++++++++++++++++++++++++++------------------------ 1 file changed, 79 insertions(+), 69 deletions(-) diff --git a/examples/sgh b/examples/sgh index 8ca4953..dd97074 100755 --- a/examples/sgh +++ b/examples/sgh @@ -11,18 +11,26 @@ set -o pipefail auth_file="$HOME/.sgh" +# shifu cmds sgh_cmd() { cmd_name sgh cmd_subs auth_cmd issue_cmd pr_cmd cmd_help "Example github cli implemented with shifu" } +## auth auth_cmd() { cmd_name auth cmd_subs auth_clear_cmd auth_set_cmd cmd_help "Commands for interacting authentication tokens" } +auth_clear_cmd() { + cmd_name clear + cmd_func auth_clear + cmd_help "Clear token for authentication" +} + auth_set_cmd() { cmd_name set cmd_func auth_set @@ -32,20 +40,7 @@ auth_set_cmd() { https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens" } -auth_set() { - printf "$PAT" > "$auth_file" -} - -auth_clear_cmd() { - cmd_name clear - cmd_func auth_clear - cmd_help "Clear token for authentication" -} - -auth_clear() { - [ -f "$HOME/.sgh" ] && rm "$HOME/.sgh" -} - +## issue issue_cmd() { cmd_name issue cmd_subs issue_list_cmd issue_view_cmd @@ -63,18 +58,6 @@ issue_list_cmd() { cmd_optd -n --number -- NUMBER 10 "Max number of results" } -issue_list() { - _resolve_repo - q_val="repo:$REPO+is:issue" - [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" - _update_query q "$q_val" - _update_query per_page "$NUMBER" - _gh_api_query "search/issues" | \ - jq -r '.items[] | - "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table -} - issue_view_cmd() { cmd_name view cmd_func issue_view @@ -83,19 +66,7 @@ issue_view_cmd() { cmd_argr ISSUE_NUMBER "Issue number to view" } -issue_view() { - _resolve_repo - _gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ - jq -r '"title:\t\(.title) -state:\t\(.state) -author:\t\(.user.login) -labels:\t\([.labels[].name] | join(", ")) -comments:\t\(.comments) -number:\t\(.number) --- -\(.body)"' -} - +## pr pr_cmd() { cmd_name pr cmd_subs pr_list_cmd pr_view_cmd @@ -113,9 +84,28 @@ pr_list_cmd() { cmd_optd -n --number -- NUMBER 10 "Max number of results" } -pr_list() { +pr_view_cmd() { + cmd_name view + cmd_func pr_view + cmd_help "View pull request in a GitHub repository" + + cmd_argr PR_NUMBER "Pull request number to view" +} + +# implementations +## auth +auth_clear() { + [ -f "$HOME/.sgh" ] && rm "$HOME/.sgh" +} + +auth_set() { + printf "$PAT" > "$auth_file" +} + +## issue +issue_list() { _resolve_repo - q_val="repo:$REPO+is:pr" + q_val="repo:$REPO+is:issue" [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" _update_query q "$q_val" _update_query per_page "$NUMBER" @@ -125,12 +115,30 @@ pr_list() { "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table } -pr_view_cmd() { - cmd_name view - cmd_func pr_view - cmd_help "View pull request in a GitHub repository" +issue_view() { + _resolve_repo + _gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ + jq -r '"title:\t\(.title) +state:\t\(.state) +author:\t\(.user.login) +labels:\t\([.labels[].name] | join(", ")) +comments:\t\(.comments) +number:\t\(.number) +-- +\(.body)"' +} - cmd_argr PR_NUMBER "Pull request number to view" +## pr +pr_list() { + _resolve_repo + q_val="repo:$REPO+is:pr" + [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" + _update_query q "$q_val" + _update_query per_page "$NUMBER" + _gh_api_query "search/issues" | \ + jq -r '.items[] | + "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | + "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table } pr_view() { @@ -147,29 +155,7 @@ deletions:\t\(.deletions) \(.body)"' } -_table() { - local input widths - input=$(cat) - [ -z "$input" ] && return - widths=$(printf "%s" "$input" | jq -Rrs ' - split("\n") | map(select(length > 0)) - | map(split("\t") | map(length)) - | transpose | map(max) | @tsv') - IFS=$'\t' read -ra w <<< "$widths" - - while IFS=$'\t' read -ra fields; do - last=$((${#fields[@]} - 1)) - for i in "${!fields[@]}"; do - if [ "$i" -lt "$last" ]; then - printf "%s%*s " "${fields[$i]}" "$((w[i] - ${#fields[$i]}))" "" - else - printf "%s" "${fields[$i]}" - fi - done - printf "\n" - done <<< "$input" -} - +# helpers _resolve_repo() { [ "$REPO" != "current repo" ] && return origin_url=$(git config --get remote.origin.url) @@ -184,6 +170,7 @@ _resolve_repo() { REPO="${REPO%.git}" } +## api _update_query() { [ -z "$2" ] && return [ -z "$query" ] && query="?" || query="$query&" @@ -211,5 +198,28 @@ _gh_api() { printf "%s" "$result" } +## formatting +_table() { + local input widths + input=$(cat) + [ -z "$input" ] && return + widths=$(printf "%s" "$input" | jq -Rrs ' + split("\n") | map(select(length > 0)) + | map(split("\t") | map(length)) + | transpose | map(max) | @tsv') + IFS=$'\t' read -ra w <<< "$widths" + + while IFS=$'\t' read -ra fields; do + last=$((${#fields[@]} - 1)) + for i in "${!fields[@]}"; do + if [ "$i" -lt "$last" ]; then + printf "%s%*s " "${fields[$i]}" "$((w[i] - ${#fields[$i]}))" "" + else + printf "%s" "${fields[$i]}" + fi + done + printf "\n" + done <<< "$input" +} shifu_run sgh_cmd "$@" From 66a9e37f5374195b0cae8ec114368e43ab0fc88c Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 09:57:24 -0400 Subject: [PATCH 12/20] Update help strings to more closely match gh's --- examples/sgh | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/sgh b/examples/sgh index dd97074..0a88561 100755 --- a/examples/sgh +++ b/examples/sgh @@ -22,7 +22,7 @@ sgh_cmd() { auth_cmd() { cmd_name auth cmd_subs auth_clear_cmd auth_set_cmd - cmd_help "Commands for interacting authentication tokens" + cmd_help "Authenticate sgh with GitHub" } auth_clear_cmd() { @@ -44,24 +44,24 @@ auth_set_cmd() { issue_cmd() { cmd_name issue cmd_subs issue_list_cmd issue_view_cmd - cmd_help "Commands for interacting with github repository issues" + cmd_help "Manage issues" - cmd_optd :defer: -R --repo -- REPO "current repo" "Github repository in OWNER/REPO format" + cmd_optd :defer: -R --repo -- REPO "current repo" "Select another repository using the [HOST/]OWNER/REPO format" } issue_list_cmd() { cmd_name list cmd_func issue_list - cmd_help "List issues in a GitHub repository" + cmd_help "List issues in a repository" - cmd_optd -s --state -- STATE open "State of issue: open, closed, all" - cmd_optd -n --number -- NUMBER 10 "Max number of results" + cmd_optd -s --state -- STATE open "Filter by state: {open|closed|all}" + cmd_optd -n --number -- NUMBER 10 "Maximum number of issues to fetch" } issue_view_cmd() { cmd_name view cmd_func issue_view - cmd_help "View issue in a GitHub repository" + cmd_help "View an issue" cmd_argr ISSUE_NUMBER "Issue number to view" } @@ -70,24 +70,24 @@ issue_view_cmd() { pr_cmd() { cmd_name pr cmd_subs pr_list_cmd pr_view_cmd - cmd_help "Commands for interacting with github repository pull requests" + cmd_help "Manage pull requests" - cmd_optd :defer: -R --repo -- REPO "current repo" "Github repository in OWNER/REPO format" + cmd_optd :defer: -R --repo -- REPO "current repo" "Select another repository using the [HOST/]OWNER/REPO format" } pr_list_cmd() { cmd_name list cmd_func pr_list - cmd_help "List pull requests in a GitHub repository" + cmd_help "List pull requests in a repository" - cmd_optd -s --state -- STATE open "State of PR: open, closed, all" - cmd_optd -n --number -- NUMBER 10 "Max number of results" + cmd_optd -s --state -- STATE open "Filter by state: {open|closed|all}" + cmd_optd -n --number -- NUMBER 10 "Maximum number of items to fetch" } pr_view_cmd() { cmd_name view cmd_func pr_view - cmd_help "View pull request in a GitHub repository" + cmd_help "View a pull request" cmd_argr PR_NUMBER "Pull request number to view" } From 070e490f83dba0b3d086c17803737bb59de18b55 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 14:06:03 -0400 Subject: [PATCH 13/20] Experiment with graphql api for json output --- examples/sgh | 80 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/examples/sgh b/examples/sgh index 0a88561..0376164 100755 --- a/examples/sgh +++ b/examples/sgh @@ -56,6 +56,7 @@ issue_list_cmd() { cmd_optd -s --state -- STATE open "Filter by state: {open|closed|all}" cmd_optd -n --number -- NUMBER 10 "Maximum number of issues to fetch" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" } issue_view_cmd() { @@ -105,14 +106,66 @@ auth_set() { ## issue issue_list() { _resolve_repo - q_val="repo:$REPO+is:issue" - [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" - _update_query q "$q_val" - _update_query per_page "$NUMBER" - _gh_api_query "search/issues" | \ - jq -r '.items[] | + if [ -n "$JSON" ]; then + owner="${REPO%/*}"; name="${REPO#*/}" + case "$STATE" in + open) states="[OPEN]" ;; + closed) states="[CLOSED]" ;; + all) states="[OPEN, CLOSED]" ;; + esac + query='query { + repository(owner: "'"$owner"'", name: "'"$name"'") { + issues(first: '"$NUMBER"', states: '"$states"', orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + number title body url id state stateReason isPinned closed + createdAt updatedAt closedAt + author { + login + ... on User { id name } + ... on Bot { id } + } + labels(first: 100) { nodes { id name description color } } + assignees(first: 50) { nodes { id login name } } + milestone { number title description dueOn } + comments(first: 50) { + nodes { + id author { login } authorAssociation body createdAt + includesCreatedEdit isMinimized minimizedReason url viewerDidAuthor + reactionGroups { content users { totalCount } } + } + } + } + } + } + }' + _gh_graphql "$query" | jq " + .data.repository.issues.nodes | map({ + number, state, title, body, url, id, isPinned, closed, + stateReason: (.stateReason // \"\"), + createdAt, updatedAt, closedAt, + author: { + id: (.author.id // \"\"), + is_bot: (.author | has(\"name\") | not), + login: .author.login, + name: (.author.name // .author.login) + }, + labels: .labels.nodes, + assignees: (.assignees.nodes | map({id, is_bot: false, login, name: (.name // .login)})), + milestone, + comments: (.comments.nodes | map(. + { + minimizedReason: (.minimizedReason // \"\"), + reactionGroups: [.reactionGroups[] | select(.users.totalCount > 0)] + })) + } | {$JSON})" + else + q_val="repo:$REPO+is:issue" + [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" + _update_query q "$q_val" + _update_query per_page "$NUMBER" + _gh_api_query "search/issues" | jq -r '.items[] | "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table + fi } issue_view() { @@ -198,6 +251,21 @@ _gh_api() { printf "%s" "$result" } +_gh_graphql() { + [ -f "$auth_file" ] && auth_token=$(cat "$auth_file") + body=$(jq -n --arg q "$1" '{query: $q}') + result=$(curl -sL \ + -H "Authorization: Bearer $auth_token" \ + -H "Content-Type: application/json" \ + -d "$body" \ + https://api.github.com/graphql) + if printf "%s" "$result" | jq -e 'has("errors")' > /dev/null 2>&1; then + printf "%s" "$result" | jq -r '.errors[].message' >&2 + exit 1 + fi + printf "%s" "$result" +} + ## formatting _table() { local input widths From 36e4efc599402c40b81c5a84cdf682743aa217b9 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 15:51:23 -0400 Subject: [PATCH 14/20] Make gql query builder --- examples/sgh | 80 +++++++++++++++++++++++++++------------------------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/examples/sgh b/examples/sgh index 0376164..d8cd3e2 100755 --- a/examples/sgh +++ b/examples/sgh @@ -113,50 +113,19 @@ issue_list() { closed) states="[CLOSED]" ;; all) states="[OPEN, CLOSED]" ;; esac + _gql_build "$JSON" query='query { repository(owner: "'"$owner"'", name: "'"$name"'") { - issues(first: '"$NUMBER"', states: '"$states"', orderBy: {field: CREATED_AT, direction: DESC}) { - nodes { - number title body url id state stateReason isPinned closed - createdAt updatedAt closedAt - author { - login - ... on User { id name } - ... on Bot { id } - } - labels(first: 100) { nodes { id name description color } } - assignees(first: 50) { nodes { id login name } } - milestone { number title description dueOn } - comments(first: 50) { - nodes { - id author { login } authorAssociation body createdAt - includesCreatedEdit isMinimized minimizedReason url viewerDidAuthor - reactionGroups { content users { totalCount } } - } - } - } + issues( + first: '"$NUMBER"', + states: '"$states"', + orderBy: { field: CREATED_AT, direction: DESC } + ) { + nodes { '"$gql"' } } } }' - _gh_graphql "$query" | jq " - .data.repository.issues.nodes | map({ - number, state, title, body, url, id, isPinned, closed, - stateReason: (.stateReason // \"\"), - createdAt, updatedAt, closedAt, - author: { - id: (.author.id // \"\"), - is_bot: (.author | has(\"name\") | not), - login: .author.login, - name: (.author.name // .author.login) - }, - labels: .labels.nodes, - assignees: (.assignees.nodes | map({id, is_bot: false, login, name: (.name // .login)})), - milestone, - comments: (.comments.nodes | map(. + { - minimizedReason: (.minimizedReason // \"\"), - reactionGroups: [.reactionGroups[] | select(.users.totalCount > 0)] - })) - } | {$JSON})" + _gh_graphql "$query" | jq ".data.repository.issues.nodes | $proj" else q_val="repo:$REPO+is:issue" [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" @@ -266,6 +235,39 @@ _gh_graphql() { printf "%s" "$result" } +# populate gql & proj varibles with values from gql_ / proj_ variables +# (defined below) for fields that need custom GraphQL fragments or projection. +# Plain fields fall through to the field name by default +_gql_build() { + local g_var p_var projs="" fields=() + gql="" + IFS=', ' read -ra fields <<< "$1" + for f in "${fields[@]}"; do + [ -z "$f" ] && continue + g_var="gql_$f" p_var="proj_$f" + gql+=" ${!g_var:-$f}" + projs+="${!p_var:-$f}, " + done + proj="map({${projs%, }})" +} + +### graphql fragments / projections +gql_author='author { login ... on User { id name } ... on Bot { id } }' +proj_author='author: { id: (.author.id // ""), is_bot: (.author | has("name") | not), login: .author.login, name: (.author.name // .author.login) }' + +gql_labels='labels(first: 100) { nodes { id name description color } }' +proj_labels='labels: .labels.nodes' + +gql_assignees='assignees(first: 50) { nodes { id login name } }' +proj_assignees='assignees: (.assignees.nodes | map({id, is_bot: false, login, name: (.name // .login)}))' + +gql_milestone='milestone { number title description dueOn }' + +proj_stateReason='stateReason: (.stateReason // "")' + +gql_comments='comments(first: 50) { nodes { id author { login } authorAssociation body createdAt includesCreatedEdit isMinimized minimizedReason url viewerDidAuthor reactionGroups { content users { totalCount } } } }' +proj_comments='comments: (.comments.nodes | map(. + { minimizedReason: (.minimizedReason // ""), reactionGroups: [.reactionGroups[] | select(.users.totalCount > 0)] }))' + ## formatting _table() { local input widths From a126a08bf30e585c742a1650202cf3a0402791d7 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 16:49:19 -0400 Subject: [PATCH 15/20] Add merged state handling to pr list --- examples/sgh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/sgh b/examples/sgh index d8cd3e2..61a7ce6 100755 --- a/examples/sgh +++ b/examples/sgh @@ -81,7 +81,7 @@ pr_list_cmd() { cmd_func pr_list cmd_help "List pull requests in a repository" - cmd_optd -s --state -- STATE open "Filter by state: {open|closed|all}" + cmd_optd -s --state -- STATE open "Filter by state: {open|closed|merged|all}" cmd_optd -n --number -- NUMBER 10 "Maximum number of items to fetch" } @@ -154,13 +154,18 @@ number:\t\(.number) pr_list() { _resolve_repo q_val="repo:$REPO+is:pr" - [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" + case "$STATE" in + merged) q_val="$q_val+is:merged" ;; + all) ;; + *) q_val="$q_val+state:$STATE" ;; + esac _update_query q "$q_val" _update_query per_page "$NUMBER" _gh_api_query "search/issues" | \ jq -r '.items[] | "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table + (if .pull_request.merged_at then "merged" else .state end) as $state | + "#\(.number)\t\($state)\t\(.title)\t\(.user.login)\t\($date)"' | _table } pr_view() { From 553037641cce5f05e93daa2cc93a97ed1aaa33f4 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 17:02:39 -0400 Subject: [PATCH 16/20] Add json flag to issue view --- examples/sgh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/sgh b/examples/sgh index 61a7ce6..aa26f41 100755 --- a/examples/sgh +++ b/examples/sgh @@ -64,6 +64,7 @@ issue_view_cmd() { cmd_func issue_view cmd_help "View an issue" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" cmd_argr ISSUE_NUMBER "Issue number to view" } @@ -125,7 +126,7 @@ issue_list() { } } }' - _gh_graphql "$query" | jq ".data.repository.issues.nodes | $proj" + _gh_graphql "$query" | jq ".data.repository.issues.nodes | map($proj)" else q_val="repo:$REPO+is:issue" [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" @@ -139,8 +140,18 @@ issue_list() { issue_view() { _resolve_repo - _gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ - jq -r '"title:\t\(.title) + if [ -n "$JSON" ]; then + owner="${REPO%/*}"; name="${REPO#*/}" + _gql_build "$JSON" + query='query { + repository(owner: "'"$owner"'", name: "'"$name"'") { + issue(number: '"$ISSUE_NUMBER"') { '"$gql"' } + } + }' + _gh_graphql "$query" | jq ".data.repository.issue | $proj" + else + _gh_api_query "repos/$REPO/issues/$ISSUE_NUMBER" | \ + jq -r '"title:\t\(.title) state:\t\(.state) author:\t\(.user.login) labels:\t\([.labels[].name] | join(", ")) @@ -148,6 +159,7 @@ comments:\t\(.comments) number:\t\(.number) -- \(.body)"' + fi } ## pr @@ -253,7 +265,7 @@ _gql_build() { gql+=" ${!g_var:-$f}" projs+="${!p_var:-$f}, " done - proj="map({${projs%, }})" + proj="{${projs%, }}" } ### graphql fragments / projections From 8ca4e0f63e4e5068e8c2db3d849e6e367dcfafae Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 19:32:04 -0400 Subject: [PATCH 17/20] Add json flag for pr subcommands --- examples/sgh | 70 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 19 deletions(-) diff --git a/examples/sgh b/examples/sgh index aa26f41..34ad68f 100755 --- a/examples/sgh +++ b/examples/sgh @@ -84,6 +84,7 @@ pr_list_cmd() { cmd_optd -s --state -- STATE open "Filter by state: {open|closed|merged|all}" cmd_optd -n --number -- NUMBER 10 "Maximum number of items to fetch" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" } pr_view_cmd() { @@ -91,6 +92,7 @@ pr_view_cmd() { cmd_func pr_view cmd_help "View a pull request" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" cmd_argr PR_NUMBER "Pull request number to view" } @@ -121,9 +123,7 @@ issue_list() { first: '"$NUMBER"', states: '"$states"', orderBy: { field: CREATED_AT, direction: DESC } - ) { - nodes { '"$gql"' } - } + ) { nodes { '"$gql"' } } } }' _gh_graphql "$query" | jq ".data.repository.issues.nodes | map($proj)" @@ -165,25 +165,56 @@ number:\t\(.number) ## pr pr_list() { _resolve_repo - q_val="repo:$REPO+is:pr" - case "$STATE" in - merged) q_val="$q_val+is:merged" ;; - all) ;; - *) q_val="$q_val+state:$STATE" ;; - esac - _update_query q "$q_val" - _update_query per_page "$NUMBER" - _gh_api_query "search/issues" | \ - jq -r '.items[] | - "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - (if .pull_request.merged_at then "merged" else .state end) as $state | - "#\(.number)\t\($state)\t\(.title)\t\(.user.login)\t\($date)"' | _table + if [ -n "$JSON" ]; then + owner="${REPO%/*}"; name="${REPO#*/}" + case "$STATE" in + open) states="[OPEN]" ;; + closed) states="[CLOSED, MERGED]" ;; + merged) states="[MERGED]" ;; + all) states="[OPEN, CLOSED, MERGED]" ;; + esac + _gql_build "$JSON" + query='query { + repository(owner: "'"$owner"'", name: "'"$name"'") { + pullRequests( + first: '"$NUMBER"', + states: '"$states"', + orderBy: { field: CREATED_AT, direction: DESC } + ) { nodes { '"$gql"' } } + } + }' + _gh_graphql "$query" | jq ".data.repository.pullRequests.nodes | map($proj)" + else + q_val="repo:$REPO+is:pr" + case "$STATE" in + merged) q_val="$q_val+is:merged" ;; + all) ;; + *) q_val="$q_val+state:$STATE" ;; + esac + _update_query q "$q_val" + _update_query per_page "$NUMBER" + _gh_api_query "search/issues" | \ + jq -r '.items[] | + "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | + (if .pull_request.merged_at then "merged" else .state end) as $state | + "#\(.number)\t\($state)\t\(.title)\t\(.user.login)\t\($date)"' | _table + fi } pr_view() { _resolve_repo - _gh_api_query "repos/$REPO/pulls/$PR_NUMBER" | \ - jq -r '"title:\t\(.title) + if [ -n "$JSON" ]; then + owner="${REPO%/*}"; name="${REPO#*/}" + _gql_build "$JSON" + query='query { + repository(owner: "'"$owner"'", name: "'"$name"'") { + pullRequest(number: '"$PR_NUMBER"') { '"$gql"' } + } + }' + _gh_graphql "$query" | jq ".data.repository.pullRequest | $proj" + else + _gh_api_query "repos/$REPO/pulls/$PR_NUMBER" | \ + jq -r '"title:\t\(.title) state:\t\(if .merged then "merged" else .state end) author:\t\(.user.login) number:\t\(.number) @@ -192,6 +223,7 @@ additions:\t\(.additions) deletions:\t\(.deletions) -- \(.body)"' + fi } # helpers @@ -252,7 +284,7 @@ _gh_graphql() { printf "%s" "$result" } -# populate gql & proj varibles with values from gql_ / proj_ variables +# populate gql & proj variables with values from gql_ / proj_ variables # (defined below) for fields that need custom GraphQL fragments or projection. # Plain fields fall through to the field name by default _gql_build() { From 6c17d2921c3549c7f54ec3d74964ecdf919b2ad4 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 20:24:06 -0400 Subject: [PATCH 18/20] Update script doc string --- examples/sgh | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/sgh b/examples/sgh index 34ad68f..40f463c 100755 --- a/examples/sgh +++ b/examples/sgh @@ -1,9 +1,7 @@ #! /bin/bash -# Example shifu cli to partially reproduce similar functionality that -# the github cli, gh, provides. Wrapper around curl commands to the -# github rest api and jq for response parsing. Documentation: -# https://docs.github.com/en/rest?apiVersion=2022-11-28 +# Example shifu cli to partially reproduce the github cli, gh, in shell script. +# Wrapper around curl commands to the # github rest api and jq for response parsing. set -o pipefail @@ -285,7 +283,7 @@ _gh_graphql() { } # populate gql & proj variables with values from gql_ / proj_ variables -# (defined below) for fields that need custom GraphQL fragments or projection. +# (defined below) for fields that need custom graphql fragments or projection. # Plain fields fall through to the field name by default _gql_build() { local g_var p_var projs="" fields=() From 32fdffad1dd300a36a0a51cefb954191cfb14868 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 20:39:55 -0400 Subject: [PATCH 19/20] Get lists even closer to gh non-tty output --- examples/sgh | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/examples/sgh b/examples/sgh index 40f463c..ef6f763 100755 --- a/examples/sgh +++ b/examples/sgh @@ -131,8 +131,8 @@ issue_list() { _update_query q "$q_val" _update_query per_page "$NUMBER" _gh_api_query "search/issues" | jq -r '.items[] | - "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - "#\(.number)\t\(.state)\t\(.title)\t\(.user.login)\t\($date)"' | _table + (.labels | map(.name) | join(",")) as $labels | + "\(.number)\t\(.state | ascii_upcase)\t\(.title)\t\($labels)\t\(.updated_at)"' | _table fi } @@ -183,19 +183,18 @@ pr_list() { }' _gh_graphql "$query" | jq ".data.repository.pullRequests.nodes | map($proj)" else - q_val="repo:$REPO+is:pr" case "$STATE" in - merged) q_val="$q_val+is:merged" ;; - all) ;; - *) q_val="$q_val+state:$STATE" ;; + merged) _update_query state closed ;; + *) _update_query state "$STATE" ;; esac - _update_query q "$q_val" _update_query per_page "$NUMBER" - _gh_api_query "search/issues" | \ - jq -r '.items[] | - "\(.created_at[5:7])/\(.created_at[8:10])/\(.created_at[0:4])" as $date | - (if .pull_request.merged_at then "merged" else .state end) as $state | - "#\(.number)\t\($state)\t\(.title)\t\(.user.login)\t\($date)"' | _table + filter='.[]' + [ "$STATE" = "merged" ] && filter='.[] | select(.merged_at)' + _gh_api_query "repos/$REPO/pulls" | \ + jq -r "$filter"' | + (if .merged_at then "MERGED" else .state | ascii_upcase end) as $state | + (if .head.repo.full_name == .base.repo.full_name then .head.ref else .head.label end) as $branch | + "\(.number)\t\(.title)\t\($branch)\t\($state)\t\(.created_at)"' | _table fi } From 5066b4da5efe6f797a5ec73ee7148b1baddf7444 Mon Sep 17 00:00:00 2001 From: Cary Goltermann Date: Sun, 26 Apr 2026 20:50:53 -0400 Subject: [PATCH 20/20] Add header to tables --- examples/sgh | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/examples/sgh b/examples/sgh index ef6f763..d02990d 100755 --- a/examples/sgh +++ b/examples/sgh @@ -54,7 +54,7 @@ issue_list_cmd() { cmd_optd -s --state -- STATE open "Filter by state: {open|closed|all}" cmd_optd -n --number -- NUMBER 10 "Maximum number of issues to fetch" - cmd_optd --json -- JSON "" "Output JSON with the specified fields" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" } issue_view_cmd() { @@ -62,7 +62,7 @@ issue_view_cmd() { cmd_func issue_view cmd_help "View an issue" - cmd_optd --json -- JSON "" "Output JSON with the specified fields" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" cmd_argr ISSUE_NUMBER "Issue number to view" } @@ -82,7 +82,7 @@ pr_list_cmd() { cmd_optd -s --state -- STATE open "Filter by state: {open|closed|merged|all}" cmd_optd -n --number -- NUMBER 10 "Maximum number of items to fetch" - cmd_optd --json -- JSON "" "Output JSON with the specified fields" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" } pr_view_cmd() { @@ -90,7 +90,7 @@ pr_view_cmd() { cmd_func pr_view cmd_help "View a pull request" - cmd_optd --json -- JSON "" "Output JSON with the specified fields" + cmd_optd --json -- JSON "" "Output JSON with the specified fields" cmd_argr PR_NUMBER "Pull request number to view" } @@ -130,9 +130,11 @@ issue_list() { [ "$STATE" != "all" ] && q_val="$q_val+state:$STATE" _update_query q "$q_val" _update_query per_page "$NUMBER" - _gh_api_query "search/issues" | jq -r '.items[] | - (.labels | map(.name) | join(",")) as $labels | - "\(.number)\t\(.state | ascii_upcase)\t\(.title)\t\($labels)\t\(.updated_at)"' | _table + _gh_api_query "search/issues" | jq -r ' + "ID\tSTATE\tTITLE\tLABELS\tUPDATED", + (.items[] | + (.labels | map(.name) | join(",")) as $labels | + "\(.number)\t\(.state | ascii_upcase)\t\(.title)\t\($labels)\t\(.updated_at)")' | _table fi } @@ -191,10 +193,11 @@ pr_list() { filter='.[]' [ "$STATE" = "merged" ] && filter='.[] | select(.merged_at)' _gh_api_query "repos/$REPO/pulls" | \ - jq -r "$filter"' | - (if .merged_at then "MERGED" else .state | ascii_upcase end) as $state | - (if .head.repo.full_name == .base.repo.full_name then .head.ref else .head.label end) as $branch | - "\(.number)\t\(.title)\t\($branch)\t\($state)\t\(.created_at)"' | _table + jq -r '"ID\tTITLE\tBRANCH\tSTATE\tCREATED", + ('"$filter"' | + (if .merged_at then "MERGED" else .state | ascii_upcase end) as $state | + (if .head.repo.full_name == .base.repo.full_name then .head.ref else .head.label end) as $branch | + "\(.number)\t\(.title)\t\($branch)\t\($state)\t\(.created_at)")' | _table fi } @@ -325,7 +328,13 @@ _table() { | transpose | map(max) | @tsv') IFS=$'\t' read -ra w <<< "$widths" - while IFS=$'\t' read -ra fields; do + while IFS= read -r line; do + local fields=() rest="$line" + while true; do + fields+=("${rest%%$'\t'*}") + [[ "$rest" != *$'\t'* ]] && break + rest="${rest#*$'\t'}" + done last=$((${#fields[@]} - 1)) for i in "${!fields[@]}"; do if [ "$i" -lt "$last" ]; then