diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 99005bc..ccab02f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -9,7 +9,7 @@ jobs: code-quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Spell Check # https://github.com/crate-ci/typos uses: crate-ci/typos@master diff --git a/gh-notify b/gh-notify index 5c58399..7278106 100755 --- a/gh-notify +++ b/gh-notify @@ -22,6 +22,43 @@ export GH_REST_API_VERSION="X-GitHub-Api-Version:2022-11-28" # Enable terminal-style output even when the output is redirected. export GH_FORCE_TTY=1 +# The maximum number of notifications per page (set by GitHub) +export GH_NOTIFY_PER_PAGE_LIMIT=50 +# Assign 'GH_NOTIFY_DEBUG_MODE' with 'true' to see more information +export GH_NOTIFY_DEBUG_MODE=${GH_NOTIFY_DEBUG_MODE:-false} +if $GH_NOTIFY_DEBUG_MODE; then + export gh_notify_debug_log="${BASH_SOURCE%/*}/gh_notify_debug.log" + + # Tell the user where we saved the debug information + trap 'echo [DEBUG] $gh_notify_debug_log' EXIT + + # Clear the file on every run + : >"$gh_notify_debug_log" + + # Unset GH_FORCE_TTY to avoid unnecessary color codes in the debug file + unset GH_FORCE_TTY + + # Redirect stdout and stderr to the terminal and a file + exec &> >(tee -a "$gh_notify_debug_log") + + # [DISABLED] 'GH_DEBUG' sends the output to file descriptor 2, but these error messages can be + # caught by adding '2>&5' to all gh api calls, but this would also hide the actual error message + # from a failed gh api call. It would be great to have an actual environment variable like + # 'BASH_XTRACEFD' to set the desired file descriptor for the verbose output of GH_DEBUG + + # 'GH_DEBUG' is useful for determining why a call to the GitHub API might have failed + # export GH_DEBUG=api + # Redirect possible errors and debug information from 'gh api' calls to a file + # exec 5> >(tee -a "$gh_notify_debug_log") + + # Redirect xtrace output to a file + exec 6>>"$gh_notify_debug_log" + # Write the trace output to file descriptor 6 + export BASH_XTRACEFD=6 + # More verbose execution trace prompt + export PS4='+$(date +%Y-%m-%d:%H:%M:%S) ${FUNCNAME[0]:-}:L${LINENO:-}: ' + set -o xtrace +fi # 'SHLVL' variable represents the nesting level of the current shell export NESTED_START_LVL="$SHLVL" export FINAL_MSG='All caught up!' @@ -34,7 +71,7 @@ export WHITE_BOLD='\033[1m' export exclusion_string='XXX_BOGUS_STRING_THAT_SHOULD_NOT_EXIST_XXX' export filter_string='' -export num_notifications='0' +export num_notifications=0 export only_participating_flag=false export include_all_flag=false export preview_window_visibility='hidden' @@ -56,7 +93,7 @@ die() { print_help_text() { local help_text help_text=$( - cat <&2 "." - gh api --header "$GH_REST_API_VERSION" --method GET notifications --cache=0s \ - --field per_page="$local_page_size" --field page="$page_num" \ + local page_num="$1" + command gh api --header "$GH_REST_API_VERSION" --method GET notifications --cache=0s \ + --field per_page="$GH_NOTIFY_PER_PAGE_LIMIT" --field page="$page_num" \ --field participating="$only_participating_flag" --field all="$include_all_flag" \ --jq \ $'def colors: @@ -165,7 +201,7 @@ get_notifs() { repo_full_name: .repository.full_name, unread_symbol: colored((if .unread then "\u25cf" else "\u00a0" end); "magenta"), # make sure each outcome has an equal number of fields separated by spaces - timefmt: colored((.last_read_at // .updated_at | fromdateiso8601) as $time_sec | + timefmt: colored(((if .unread then .last_read_at // .updated_at else .updated_at end) | fromdateiso8601) as $time_sec | # difference is less than one hour if ((now - $time_sec) / 3600) < 1 then (now - $time_sec) / 60 | floor | tostring + "min ago" @@ -200,59 +236,45 @@ get_notifs() { } print_notifs() { - local all_notifs page_num page new_notifs graphql_query_discussion result - all_notifs='' - page_num=1 - graphql_query_discussion=$'query ($filter: String!) { search(query: $filter, type: DISCUSSION, first: 1) { nodes { ... on Discussion { number }}}}' - while true; do - page=$(get_notifs $page_num) || die "Failed to get notifications." - if [ "$page" == "" ]; then - break + local local_page_size page new_notifs result + local page_num=1 + local total_requested="$num_notifications" # Total number of notifications requested + local fetched_count=0 # A counter for the number of fetched notifications + local all_notifs="" + + while :; do + local_page_size=$((total_requested - fetched_count > GH_NOTIFY_PER_PAGE_LIMIT ? \ + GH_NOTIFY_PER_PAGE_LIMIT : total_requested - fetched_count)) + page=$(get_notifs "$page_num") || die "Failed to get notifications." + [[ -z $page ]] && break + # Print "marching ants" after `get_notifs` to indicate progress. + printf >&2 "." + + page_num=$((page_num + 1)) + # On each run, we can fetch up to 50 notifications. If a user requested 56, we can't specify + # 6 notifications 'per_page' for page number 2. This would incorrectly return notifications + # 6-11 from page number 1, which we already have. Therefore, if a user requests 56 + # notifications, we need to call the REST API twice with the maximum 'per_page' size and + # then truncate the second page accordingly. + if ((total_requested > 0)) && ((local_page_size < GH_NOTIFY_PER_PAGE_LIMIT)); then + page=$(command head -n "$local_page_size" <<<"$page") else - page_num=$((page_num + 1)) + local_page_size=$(command sed -n '$=' <<<"$page") + fi + + new_notifs=$(process_page "$page") || die "Failed to process page." + all_notifs="${all_notifs}${new_notifs}" + fetched_count=$((fetched_count + local_page_size)) + # If the number of fetched results equals the number of requested results, or if the number + # of items retrieved in this round is less than the maximum per page limit, we stop. + if ((fetched_count == total_requested)) || ((local_page_size < GH_NOTIFY_PER_PAGE_LIMIT)); then + break fi - new_notifs=$( - echo "$page" | while IFS=$'\t' read -r updated_short iso8601 thread_id thread_state \ - comment_url repo_full_name unread_symbol timefmt repo_abbreviated type url reason \ - title number; do - if grep -q "Discussion" <<<"$type"; then - # https://docs.github.com/en/search-github/searching-on-github/searching-discussions - number="#$(gh api graphql --cache=100h --raw-field filter="$title in:title updated:>=$updated_short repo:$repo_full_name" \ - --raw-field query="$graphql_query_discussion" --jq '.data.search.nodes | .[].number')" || - die "Failed GraphQL discussion query." - elif ! grep -q "^null" <<<"$url"; then - if grep -q "Commit" <<<"$type"; then - number=$(basename "$url" | head -c 7) - elif grep -q "Release" <<<"$type"; then - # directly read the output into number and prerelease variables - if IFS=$'\t' read -r number prerelease < <(gh api --cache=100h --header "$GH_REST_API_VERSION" \ - --method GET "$url" --jq '[.tag_name, .prerelease] | @tsv'); then - "$prerelease" && type="Pre-release" - else - # it may happen that URLs are retrieved but are already dead and therefore skipped - continue - fi - else - # gh api calls cost time, try to avoid them as much as possible - number=${url/*\//#} - fi - fi - printf "\n%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%b%s%b\t%s\t%s\n" \ - "$iso8601" "$thread_id" "$thread_state" "$comment_url" "$repo_full_name" \ - "$unread_symbol" "$timefmt" "$repo_abbreviated" "$type" "$GREEN" "$number" \ - "$NC" "$reason" "$title" - done - ) || die "Something went wrong" - all_notifs="$all_notifs$new_notifs" - # this is going to be a bit funky. - # if you specify a number larger than 100 - # GitHub will ignore it and give you only 100 - [[ $num_notifications != "0" ]] && break done # clear the dots we printed echo >&2 -ne "\r\033[K" - result=$(echo "$all_notifs" | grep -v "$exclusion_string" | grep "$filter_string" | column -ts $'\t') + result=$(command grep -v "$exclusion_string" <<<"$all_notifs" | command grep "$filter_string" | command column -ts $'\t') # if the value is greater than the initial start value, we assume to be in the 'fzf’ reload function if [[ -z $result && $SHLVL -gt $NESTED_START_LVL ]]; then # TODO: exit fzf automatically if the list is empty after a reload @@ -265,45 +287,120 @@ print_notifs() { fi } +# Processes a page of GitHub notifications, extracting and formatting relevant details. +process_page() { + local page="$1" + while IFS=$'\t' read -r updated_short iso8601 thread_id thread_state \ + comment_url repo_full_name unread_symbol timefmt repo_abbreviated type url reason \ + title; do + local number="" modified_type + if command grep -q "Discussion" <<<"$type"; then + number=$(process_discussion "$title" "$updated_short" "$repo_full_name") || return 1 + elif ! command grep -q "^null" <<<"$url"; then + if ! output=$(process_url "$type" "$url"); then + return 1 + fi + read -r number modified_type <<<"$output" + if [[ -z $number ]]; then + continue + fi + fi + printf "\n%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%b%s%b\t%s\t%s\n" \ + "$iso8601" "$thread_id" "$thread_state" "$comment_url" "$repo_full_name" \ + "$unread_symbol" "$timefmt" "$repo_abbreviated" "${modified_type:-$type}" \ + "$GREEN" "$number" "$NC" "$reason" "$title" + done <<<"$page" +} + +# Extracts and formats relevant information from a GitHub URL based on its type +# Returns: a number and optionally a new type, or raises an error for release types +process_url() { + local type="$1" url="$2" + local number prerelease + if command grep -q "Commit" <<<"$type"; then + command basename "$url" | command head -c 7 + elif command grep -q "Release" <<<"$type"; then + if IFS=$'\t' read -r number prerelease < <(command gh api "$url" \ + --cache=100h \ + --header "$GH_REST_API_VERSION" \ + --method GET \ + --jq '[.tag_name, .prerelease] | @tsv'); then + if "$prerelease"; then + echo "$number Pre-release" + else + echo "$number" + fi + else + # Release URLs may already be inaccessible and are therefore skipped unless in Debug + # mode. Since nothing will be sent, the notification will be skipped in the + # 'process_page' function. + if $GH_NOTIFY_DEBUG_MODE; then + die "Failed to retrieve the release information: $url" + fi + fi + else + # Minimize gh API calls as they are time-consuming + echo "${url/*\//#}" + fi +} + +# Executes a GraphQL query for Discussion search using the provided information +# Returns the found number or raises an error +process_discussion() { + local title="$1" updated_short="$2" repo_full_name="$3" + local graphql_query_discussion + # https://docs.github.com/en/search-github/searching-on-github/searching-discussions + graphql_query_discussion=$'query ($filter: String!) { + search(query: $filter, type: DISCUSSION, first: 1) { nodes { ... on Discussion { number }}}}' + command gh api graphql \ + --cache=100h \ + --raw-field query="$graphql_query_discussion" \ + --raw-field filter="$title in:title updated:>=$updated_short repo:$repo_full_name" \ + --jq '.data.search.nodes | "#\(.[].number)"' || die "Failed GraphQL discussion query." +} + highlight_output() { - if type -p delta >/dev/null; then + local bat_cmd + if command -v delta >/dev/null; then # https://dandavison.github.io/delta - delta --width "${FZF_PREVIEW_COLUMNS:-${COLUMNS:-100}}" --paging=never - elif type -p bat >/dev/null; then - # https://github.com/sharkdp/bat - bat --color=always --plain --language diff --no-pager \ - --terminal-width="${FZF_PREVIEW_COLUMNS:-${COLUMNS:-100}}" + command delta --width "${FZF_PREVIEW_COLUMNS:-${COLUMNS:-100}}" --paging=never else - cat + # Resolve 'bat' command (could be installed as 'bat' or 'batcat' depends on the OS) + if bat_cmd=$(command -v bat || command -v batcat); then + command $bat_cmd --color=always --plain --language diff --no-pager \ + --terminal-width="${FZF_PREVIEW_COLUMNS:-${COLUMNS:-100}}" + else + command cat + fi fi } open_in_browser() { local comment_number date time repo_full_name type number unhashed_num IFS=' ' read -r _ _ _ comment_number repo_full_name _ date time _ type number _ <<<"$1" - unhashed_num=$(tr -d "#" <<<"$number") + unhashed_num=$(command tr -d "#" <<<"$number") case "$type" in CheckSuite) "$python_executable" -m webbrowser "https://github.com/${repo_full_name}/actions" ;; Commit) - gh browse "$number" --repo "$repo_full_name" + command gh browse "$number" --repo "$repo_full_name" ;; Discussion) "$python_executable" -m webbrowser "https://github.com/${repo_full_name}/discussions/${unhashed_num}" ;; Issue | PullRequest) if [[ $comment_number == "$unhashed_num" || $comment_number == null ]]; then - gh issue view "$number" --web --repo "$repo_full_name" + command gh issue view "$number" --web --repo "$repo_full_name" else "$python_executable" -m webbrowser "https://github.com/${repo_full_name}/issues/${unhashed_num}#issuecomment-${comment_number}" fi ;; Pre-release | Release) - gh release view "$number" --web --repo "$repo_full_name" + command gh release view "$number" --web --repo "$repo_full_name" ;; *) - gh repo view --web "$repo_full_name" + command gh repo view --web "$repo_full_name" ;; esac } @@ -318,18 +415,18 @@ view_notification() { printf "[%s %s - %s]\n" "$date" "$time" "$type" case "$type" in Commit) - gh api --header "$GH_REST_API_VERSION" --cache=24h \ + command gh api --header "$GH_REST_API_VERSION" --cache=24h \ --method GET "repos/$repo_full_name/commits/$number" --jq '.files[].patch' | highlight_output ;; Issue) # use the '--comments' flag only if 'all_comments' exists and is not null - gh issue view "$number" --repo "$repo_full_name" ${all_comments:+"--comments"} + command gh issue view "$number" --repo "$repo_full_name" ${all_comments:+"--comments"} ;; PullRequest) - gh pr view "$number" --repo "$repo_full_name" ${all_comments:+"--comments"} + command gh pr view "$number" --repo "$repo_full_name" ${all_comments:+"--comments"} ;; Pre-release | Release) - gh release view "$number" --repo "$repo_full_name" + command gh release view "$number" --repo "$repo_full_name" ;; *) printf "Seeing the preview of a %b%s%b is not supported.\n" "$WHITE_BOLD" "$type" "$NC" @@ -341,7 +438,7 @@ mark_all_read() { local iso_time IFS=' ' read -r iso_time _ <<<"$1" # https://docs.github.com/en/rest/activity/notifications#mark-notifications-as-read - gh api --silent --header "$GH_REST_API_VERSION" --method PUT notifications \ + command gh api --silent --header "$GH_REST_API_VERSION" --method PUT notifications \ --raw-field last_read_at="$iso_time" --field read=true } @@ -349,7 +446,7 @@ mark_individual_read() { local thread_id thread_state IFS=' ' read -r _ thread_id thread_state _ <<<"$1" if [ "$thread_state" = "UNREAD" ]; then - gh api --silent --header "$GH_REST_API_VERSION" --method PATCH "notifications/threads/${thread_id}" + command gh api --silent --header "$GH_REST_API_VERSION" --method PATCH "notifications/threads/${thread_id}" fi } @@ -370,6 +467,7 @@ select_notif() { # 'SHELL="$(which bash)"' is needed to use exported functions when the default shell # is not bash export -f print_help_text print_notifs get_notifs + export -f process_page process_discussion process_url export -f highlight_output open_in_browser view_notification export -f mark_all_read mark_individual_read # The 'die' function is not exported because 'fzf' warns you about the error in @@ -380,14 +478,14 @@ select_notif() { # but a user could have them in their ‘FZF_DEFAULT_OPTS’ # and so the lines would get screwed up and fail if we don't take that into account. output=$( - SHELL="$(which bash)" fzf <<<"$1" \ + SHELL="$(which bash)" command fzf \ --ansi \ --bind "btab:change-preview-window(75%:nohidden|75%:down:nohidden:border-top|nohidden)" \ --bind "change:first" \ --bind "ctrl-a:execute-silent(mark_all_read {})+reload:print_notifs || true" \ --bind "ctrl-b:execute-silent:open_in_browser {}" \ - --bind "ctrl-d:toggle-preview+change-preview:if grep -q PullRequest <<<{10}; then gh pr diff {11} --repo {5} | highlight_output; else view_notification {}; fi" \ - --bind "ctrl-p:toggle-preview+change-preview:if grep -q PullRequest <<<{10}; then gh pr diff {11} --patch --repo {5} | highlight_output; else view_notification {}; fi" \ + --bind "ctrl-d:toggle-preview+change-preview:if command grep -q PullRequest <<<{10}; then command gh pr diff {11} --repo {5} | highlight_output; else view_notification {}; fi" \ + --bind "ctrl-p:toggle-preview+change-preview:if command grep -q PullRequest <<<{10}; then command gh pr diff {11} --patch --repo {5} | highlight_output; else view_notification {}; fi" \ --bind "ctrl-r:reload:print_notifs || true" \ --bind "ctrl-t:execute-silent(mark_individual_read {})+reload:print_notifs || true" \ --bind "enter:execute:view_notification --all_comments {} | less ${less_args[*]} >/dev/tty" \ @@ -408,14 +506,14 @@ select_notif() { --print-query \ --prompt "GitHub Notifications > " \ --reverse \ - --with-nth 6.. + --with-nth 6.. <<<"$1" ) # actions that close fzf are defined below # 1st line ('--print-query'): the input query string # 2nd line ('--expect'): the actual key # 3rd line: the selected line when the user pressed the key - expected_key="$(sed '1d;3d' <<<"$output")" - selected_line="$(sed '1d;2d' <<<"$output")" + expected_key="$(command sed '1d;3d' <<<"$output")" + selected_line="$(command sed '1d;2d' <<<"$output")" IFS=' ' read -r _ thread_id thread_state _ repo_full_name _ _ _ _ type num _ <<<"$selected_line" [[ -z $type ]] && exit 0 case "$expected_key" in @@ -424,8 +522,8 @@ select_notif() { exit 0 ;; ctrl-x) - if grep -qE "Issue|PullRequest" <<<"$type"; then - gh issue comment "$num" --repo "$repo_full_name" + if command grep -qE "Issue|PullRequest" <<<"$type"; then + command gh issue comment "$num" --repo "$repo_full_name" mark_individual_read "$selected_line" || die "Failed to mark the notification as read." else printf "Writing comments is only supported for %bIssues%b and %bPullRequests%b.\n" "$WHITE_BOLD" "$NC" "$WHITE_BOLD" "$NC" @@ -444,7 +542,7 @@ check_version() { local user_version declare -a ver_parts threshold_parts user_version=$(command $tool --version 2>&1 | - command grep --color=never --extended-regexp --only-matching --regexp='[0-9]+(\.[0-9]+)*' | + command command grep --color=never --extended-regexp --only-matching --regexp='[0-9]+(\.[0-9]+)*' | command sed q) IFS='.' read -ra ver_parts <<<"$user_version" @@ -464,10 +562,10 @@ update_subscription() { local graphql_mutation_update_subscription=$'mutation ($updated_state: SubscriptionState!, $node_id: ID!) { updateSubscription(input: {state: $updated_state, subscribableId: $node_id}) { subscribable { viewerSubscription }}}' local graphql_query_subscribable=$'{ __type(name: "Subscribable") { possibleTypes { name }}}' local updated_state update_text possibleTypes - if IFS=$'\t' read -r object_type node_id viewer_can_subscribe viewer_subscription < <(gh api graphql \ + if IFS=$'\t' read -r object_type node_id viewer_can_subscribe viewer_subscription < <(command gh api graphql \ --raw-field url_input="$update_subscription_url" \ --raw-field query="$graphql_query_resource" \ - --jq '.data.resource | map(.) | @tsv' 2>/dev/null); then + --jq '.data.resource | map(.) | @tsv'); then if [[ -z $object_type ]]; then die "Your input appears to be an invalid URL: '$update_subscription_url'." elif [[ $viewer_subscription != "SUBSCRIBED" && ! $viewer_can_subscribe ]]; then @@ -501,7 +599,7 @@ update_subscription() { # subscription status is automatically set to "IGNORED" and can never be set # to "UNSUBSCRIBED" as long as you are "SUBSCRIBED" to the Repository. This is # a design decision by GitHub. - updated_state=$(gh api graphql --raw-field updated_state="$updated_state" \ + updated_state=$(command gh api graphql --raw-field updated_state="$updated_state" \ --raw-field node_id="$node_id" \ --raw-field query="$graphql_mutation_update_subscription" \ --jq '.data.updateSubscription.subscribable.viewerSubscription') || @@ -512,7 +610,7 @@ update_subscription() { printf "%b%s%b\n" "$DARK_GRAY" "$update_subscription_url" "$NC" exit 0 else - possibleTypes=$(gh api graphql --raw-field query="$graphql_query_subscribable" \ + possibleTypes=$(command gh api graphql --raw-field query="$graphql_query_subscribable" \ --jq '.data.__type.possibleTypes | map(.name) | join(", ")' || die "Failed GraphQL query for possibleTypes.") die "$( @@ -525,7 +623,7 @@ update_subscription() { gh_notify() { local python_version notifs - if ! type -p gh >/dev/null; then + if ! command -v gh >/dev/null; then die "install 'gh'" fi @@ -541,7 +639,7 @@ gh_notify() { if ! $print_static_flag; then for python_version in python python3; do - if type -p $python_version >/dev/null; then + if command -v $python_version >/dev/null; then python_executable=$python_version break fi @@ -550,7 +648,7 @@ gh_notify() { die "install 'python' or use the -s flag" fi - if ! type -p fzf >/dev/null; then + if ! command -v fzf >/dev/null; then die "install 'fzf' or use the -s flag" fi @@ -566,7 +664,7 @@ gh_notify() { else # remove unimportant elements from the static display # '[[:blank:]]' matches horizontal whitespace characters (spaces/ tabs) - echo "$notifs" | sed -E 's/^([^[:blank:]]+[[:blank:]]+){5}//' + command sed -E 's/^([^[:blank:]]+[[:blank:]]+){5}//' <<<"$notifs" fi } diff --git a/readme.md b/readme.md index 8da2833..ece8b3d 100644 --- a/readme.md +++ b/readme.md @@ -66,15 +66,15 @@ gh notify [Flags] ### Table Format -| Field | Description | -| ------------- | ----------------------------------- | -| unread symbol | indicates unread status | -| time | last time the notification was read | -| repo | related repository | -| type | notification type | -| number | associated number | -| reason | trigger reason | -| title | notification title | +| Field | Description | +| ------------- | ------------------------------------------------------------ | +| unread symbol | indicates unread status | +| time | time of last read for unread; otherwise, time of last update | +| repo | related repository | +| type | notification type | +| number | associated number | +| reason | trigger reason | +| title | notification title | ---