diff --git a/gh-notify b/gh-notify index 3c66f02..dfb78d8 100755 --- a/gh-notify +++ b/gh-notify @@ -2,27 +2,31 @@ set -o errexit -o nounset -o pipefail # https://www.gnu.org/software/bash/manual/bash.html#The-Set-Builtin -# ====================== Infos ======================= +############################################################################### +# Information +############################################################################### # https://docs.github.com/en/rest/activity/notifications +# https://docs.github.com/en/graphql/reference/queries # NotificationReason: # assign, author, comment, invitation, manual, mention, review_requested, security_alert, state_change, subscribed, team_mention, ci_activity # NotificationSubjectTypes: # CheckSuite, Commit, Discussion, Issue, PullRequest, Release, RepositoryVulnerabilityAlert, ... -# ====================== set variables ======================= +############################################################################### +# Set Variables +############################################################################### -# The minimum fzf version that the user needs to run all interactive commands. -MIN_FZF_VERSION="0.29.0" - -# export variables for use in child processes +# Export variables for use in child processes. +set -o allexport # https://docs.github.com/en/rest/overview/api-versions -export GH_REST_API_VERSION="X-GitHub-Api-Version:2022-11-28" +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 +# shellcheck disable=SC2034 +GH_FORCE_TTY=1 +# The maximum number of notifications per page set by GitHub. +GH_NOTIFY_PER_PAGE_LIMIT=50 -# Need to be exported because of its use in the 'print_help_text' function -set -o allexport # Customize the fzf keys using environment variables : "${GH_NOTIFY_MARK_ALL_READ_KEY:=ctrl-a}" : "${GH_NOTIFY_OPEN_BROWSER_KEY:=ctrl-b}" @@ -31,16 +35,51 @@ set -o allexport : "${GH_NOTIFY_RELOAD_KEY:=ctrl-r}" : "${GH_NOTIFY_MARK_READ_KEY:=ctrl-t}" : "${GH_NOTIFY_COMMENT_KEY:=ctrl-x}" +: "${GH_NOTIFY_TOGGLE_KEY:=ctrl-y}" : "${GH_NOTIFY_RESIZE_PREVIEW_KEY:=btab}" : "${GH_NOTIFY_VIEW_KEY:=enter}" : "${GH_NOTIFY_TOGGLE_PREVIEW_KEY:=tab}" : "${GH_NOTIFY_TOGGLE_HELP_KEY:=?}" -set +o allexport -# 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} +: "${GH_NOTIFY_DEBUG_MODE:=false}" + +# 'SHLVL' variable represents the nesting level of the current shell +NESTED_START_LVL="$SHLVL" +FINAL_MSG='All caught up!' + +# color codes +GREEN='\033[0;32m' +DARK_GRAY='\033[0;90m' +NC='\033[0m' +WHITE_BOLD='\033[1m' + +exclusion_string='XXX_BOGUS_STRING_THAT_SHOULD_NOT_EXIST_XXX' +filter_string='' +num_notifications=0 +only_participating_flag=false +include_all_flag=false +preview_window_visibility='hidden' +python_executable='' +set +o allexport + +# No need to export, since they aren't used in any child process. +print_static_flag=false +mark_read_flag=false +update_subscription_url='' + +# The minimum fzf version that the user needs to run all interactive commands. +MIN_FZF_VERSION="0.29.0" + +############################################################################### +# Debugging and Error Handling Configuration +############################################################################### + +die() { + echo ERROR: "$*" >&2 + exit 1 +} + if $GH_NOTIFY_DEBUG_MODE; then export gh_notify_debug_log="${BASH_SOURCE[0]%/*}/gh_notify_debug.log" @@ -67,6 +106,16 @@ if $GH_NOTIFY_DEBUG_MODE; then # Redirect possible errors and debug information from 'gh api' calls to a file # exec 5> >(tee -a "$gh_notify_debug_log") + # Ensure Bash 4.1+ for BASH_XTRACEFD support. + if [[ ${BASH_VERSINFO[0]} -lt 4 || (${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 1) ]]; then + die "Bash 4.1 or newer is required for debugging. Current version: ${BASH_VERSION}" + fi + + # Ensure fzf 0.51.0+ for '--with-shell' support. + MIN_FZF_VERSION="0.51.0" + # Ensure xtrace is enabled in all child processes started by 'fzf'. + FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS-} --with-shell \"$(which bash) -o xtrace -o nounset -o pipefail -c\"" + # Redirect xtrace output to a file exec 6>>"$gh_notify_debug_log" # Write the trace output to file descriptor 6 @@ -75,36 +124,11 @@ if $GH_NOTIFY_DEBUG_MODE; then 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!' - -# color codes -export GREEN='\033[0;32m' -export DARK_GRAY='\033[0;90m' -export NC='\033[0m' -export WHITE_BOLD='\033[1m' - -export exclusion_string='XXX_BOGUS_STRING_THAT_SHOULD_NOT_EXIST_XXX' -export filter_string='' -export num_notifications=0 -export only_participating_flag=false -export include_all_flag=false -export preview_window_visibility='hidden' -export python_executable='' -# not necessarily to be exported, since they are not used in any child process -print_static_flag=false -mark_read_flag=false -update_subscription_url='' -# ===================== basic functions ===================== - -die() { - echo ERROR: "$*" >&2 - exit 1 -} +############################################################################### +# Helper Functions +############################################################################### -# Create help message with colored text # IMPORTANT: Keep it synchronized with the README, but without the Examples. print_help_text() { local help_text @@ -139,6 +163,7 @@ ${WHITE_BOLD}Key Bindings fzf${NC} ${GREEN}${GH_NOTIFY_RELOAD_KEY} ${NC} reload ${GREEN}${GH_NOTIFY_MARK_READ_KEY} ${NC} mark the selected notification as read and reload ${GREEN}${GH_NOTIFY_COMMENT_KEY} ${NC} write a comment with the editor and quit + ${GREEN}${GH_NOTIFY_TOGGLE_KEY} ${NC} toggle the selected notification ${GREEN}esc ${NC} quit ${WHITE_BOLD}Table Format${NC} @@ -158,37 +183,6 @@ EOF echo -e "$help_text" } -# ====================== parse command-line options ======================= - -while getopts 'e:f:n:u:pawhsr' flag; do - case "${flag}" in - e) - FINAL_MSG="No results found." - exclusion_string="${OPTARG}" - ;; - f) - FINAL_MSG="No results found." - filter_string="${OPTARG}" - ;; - n) num_notifications="${OPTARG}" ;; - p) only_participating_flag=true ;; - u) update_subscription_url="${OPTARG}" ;; - a) include_all_flag=true ;; - w) preview_window_visibility='nohidden' ;; - s) print_static_flag=true ;; - r) mark_read_flag=true ;; - h) - print_help_text - exit 0 - ;; - *) - die "see 'gh notify -h' for help" - ;; - esac -done - -# ===================== helper functions ========================== - gh_rest_api() { command gh api --header "$GH_REST_API_VERSION" --method GET --cache=0s "$@" } @@ -503,6 +497,10 @@ view_in_pager() { view_notification --all_comments "$1" | command less "${less_args[@]}" >/dev/tty } +# Use this only when the list isn't filtered to avoid marking not displayed notifications as read. +# Check if the 'fzf' query or '-e' (exclude) or '-f' (filter) flags were used by examining +# the emptiness of '{q}' and any changes to `FINAL_MSG`, specifically if it remains "All caught up". +# TODO: The 2nd check is hacky; seek a cleaner solution with minimal code addition. mark_all_read() { local iso_time IFS=' ' read -r iso_time _ <<<"$1" @@ -513,9 +511,30 @@ mark_all_read() { mark_individual_read() { local thread_id thread_state - IFS=' ' read -r _ thread_id thread_state _ <<<"$1" - if [[ $thread_state == "UNREAD" ]]; then - gh_rest_api --silent --method PATCH "notifications/threads/${thread_id}" + declare -a array_threads=() + while IFS=' ' read -r _ thread_id thread_state _; do + if [[ $thread_state == "UNREAD" ]]; then + array_threads+=("$thread_id") + fi + done <"$1" + + if [[ ${#array_threads[@]} -eq 1 ]]; then + gh_rest_api --silent --method PATCH "notifications/threads/${array_threads[0]}" || + die "Failed to mark notifications as read." + elif [[ ${#array_threads[@]} -gt 1 ]]; then + # If there is a large number of threads to be processed, the number of background jobs can + # put pressure on the PC. Additionally, too many requests in short succession can trigger a + # rate limit by GitHub. Therefore, we process the threads in batches of 30, with a short + # delay of 0.3 seconds between each batch. This approach worked well in my tests with 200 + # notifications. + for ((i = 0; i < ${#array_threads[@]}; i += 30)); do + for j in "${array_threads[@]:i:30}"; do + # Running commands in the background of a script can cause it to hang, especially if + # the command outputs to stdout: https://tldp.org/LDP/abs/html/x9644.html#WAITHANG + gh_rest_api --silent --method PATCH "notifications/threads/${j}" &>/dev/null & + done + command sleep 0.3 + done fi } @@ -531,20 +550,18 @@ select_notif() { # a failed 'print_notifs' call, but does not display the message. # See the man page (man fzf) for an explanation of the arguments. - # '--print-query' and '--delimiter' are not strictly needed here, - # 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)" command fzf \ + SHELL="$(which bash)" FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS-} ${GH_NOTIFY_FZF_OPTS-}" command fzf \ --ansi \ --bind "${GH_NOTIFY_RESIZE_PREVIEW_KEY}:change-preview-window(75%:nohidden|75%:down:nohidden:border-top|nohidden)" \ --bind "change:first" \ - --bind "${GH_NOTIFY_MARK_ALL_READ_KEY}:execute-silent(mark_all_read {})+reload:print_notifs || true" \ + --bind "${GH_NOTIFY_MARK_ALL_READ_KEY}:select-all+execute-silent(if [[ -z {q} && \$FINAL_MSG =~ 'All caught up' ]]; then mark_all_read {}; else mark_individual_read {+f}; fi)+reload:print_notifs || true" \ --bind "${GH_NOTIFY_OPEN_BROWSER_KEY}:execute-silent:open_in_browser {}" \ --bind "${GH_NOTIFY_VIEW_DIFF_KEY}: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 "${GH_NOTIFY_VIEW_PATCH_KEY}: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 "${GH_NOTIFY_RELOAD_KEY}:reload:print_notifs || true" \ - --bind "${GH_NOTIFY_MARK_READ_KEY}:execute-silent(mark_individual_read {})+reload:print_notifs || true" \ + --bind "${GH_NOTIFY_MARK_READ_KEY}:execute-silent(mark_individual_read {+f})+reload:print_notifs || true" \ + --bind "${GH_NOTIFY_TOGGLE_KEY}:toggle+down" \ --bind "${GH_NOTIFY_VIEW_KEY}:execute:view_in_pager {}" \ --bind "${GH_NOTIFY_TOGGLE_PREVIEW_KEY}:toggle-preview+change-preview:view_notification {}" \ --bind "${GH_NOTIFY_TOGGLE_HELP_KEY}:toggle-preview+change-preview:print_help_text" \ @@ -556,21 +573,23 @@ select_notif() { --expect "esc,${GH_NOTIFY_COMMENT_KEY}" \ --header "${GH_NOTIFY_TOGGLE_HELP_KEY} help · esc quit" \ --info=inline \ - --no-multi \ + --multi \ --pointer="▶" \ --preview "view_notification {}" \ --preview-window "default:wrap:${preview_window_visibility}:60%:right:border-left" \ - --print-query \ + --no-print-query \ --prompt "GitHub Notifications > " \ --reverse \ --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="$(command sed '1d;3d' <<<"$output")" - selected_line="$(command sed '1d;2d' <<<"$output")" + # 1st line ('--expect'): the actual key + # 2nd line: the selected line when the user pressed the key + expected_key="$(command sed q <<<"$output")" + selected_line="$(command sed '1d' <<<"$output")" + if [[ $(sed -n '$=' <<<"$selected_line") -gt 1 && $expected_key != "esc" ]]; then + die "Please select only one notification for this operation." + fi IFS=' ' read -r _ thread_id thread_state _ repo_full_name _ _ _ _ type num _ <<<"$selected_line" [[ -z $type ]] && exit 0 case "$expected_key" in @@ -581,7 +600,8 @@ select_notif() { "${GH_NOTIFY_COMMENT_KEY}") 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." + # The function requires input in a file-like format + mark_individual_read <(echo "$selected_line") else printf "Writing comments is only supported for %bIssues%b and %bPullRequests%b.\n" \ "$WHITE_BOLD" "$NC" "$WHITE_BOLD" "$NC" @@ -678,8 +698,35 @@ update_subscription() { fi } -gh_notify() { +main() { local python_version notifs + # CLI Options + while getopts 'e:f:n:u:pawsrh' flag; do + case "${flag}" in + e) + FINAL_MSG="No results found." + exclusion_string="${OPTARG}" + ;; + f) + FINAL_MSG="No results found." + filter_string="${OPTARG}" + ;; + n) num_notifications="${OPTARG}" ;; + p) only_participating_flag=true ;; + u) update_subscription_url="${OPTARG}" ;; + a) include_all_flag=true ;; + w) preview_window_visibility='nohidden' ;; + s) print_static_flag=true ;; + r) mark_read_flag=true ;; + h) + print_help_text + exit 0 + ;; + *) + die "see 'gh notify -h' for help" + ;; + esac + done if ! command -v gh >/dev/null; then die "install 'gh'" @@ -690,8 +737,12 @@ gh_notify() { fi if $mark_read_flag; then - mark_all_read "" || die "Failed to mark notifications as read." - echo "All notifications have been marked as read." + if [[ $FINAL_MSG =~ 'All caught up' ]]; then + mark_all_read "" || die "Failed to mark notifications as read." + echo "All notifications have been marked as read." + else + die "Can't mark all notifications as read when either the '-e' or '-f' flag was used, as it would also mark notifications as read that are filtered out." + fi exit 0 fi @@ -709,7 +760,6 @@ gh_notify() { if ! command -v fzf >/dev/null; then die "install 'fzf' or use the -s flag" fi - check_version fzf "$MIN_FZF_VERSION" fi @@ -726,7 +776,8 @@ gh_notify() { fi } -# This will call the function only when the script is run, not when it's sourced -if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then - gh_notify -fi +############################################################################### +# Script Execution +############################################################################### + +main "$@" diff --git a/readme.md b/readme.md index 679424c..b44c8fd 100644 --- a/readme.md +++ b/readme.md @@ -62,6 +62,7 @@ gh notify [Flags] | ctrlr | reload | `GH_NOTIFY_RELOAD_KEY` | | ctrlt | mark the selected notification as read and reload | `GH_NOTIFY_MARK_READ_KEY` | | ctrlx | write a comment with the editor and quit | `GH_NOTIFY_COMMENT_KEY` | +| ctrly | toggle the selected notification | `GH_NOTIFY_TOGGLE_KEY` | | esc | quit | | ### Table Format @@ -100,6 +101,23 @@ export FZF_DEFAULT_OPTS=" --bind 'ctrl-w:preview-half-page-up,ctrl-s:preview-half-page-down'" ``` +#### GH_NOTIFY_FZF_OPTS +This environment variable lets you specify additional options and key bindings to customize the +search and display of notifications. Unlike `FZF_DEFAULT_OPTS`, `GH_NOTIFY_FZF_OPTS` specifically +applies to the `gh notify` extension. + +```sh +# --exact: Enables exact matching instead of fuzzy matching. +GH_NOTIFY_FZF_OPTS="--exact" gh notify -an 5 +``` + +```sh +# With the height flag and ~, fzf adjusts its height based on input size without filling the entire screen. +# Requires fzf +0.34.0 +GH_NOTIFY_FZF_OPTS="--height=~100%" gh notify -an 5 +``` + +#### Modifying Keybindings You can also customize the keybindings created by this extension to avoid conflicts with the ones defined by `fzf`. For example, change `ctrl-p` to `ctrl-u`: @@ -107,6 +125,11 @@ the ones defined by `fzf`. For example, change `ctrl-p` to `ctrl-u`: GH_NOTIFY_VIEW_PATCH_KEY="ctrl-u" gh notify ``` +Or, switch the binding for toggling a notification and toggling the preview. +```sh +GH_NOTIFY_TOGGLE_KEY="tab" GH_NOTIFY_TOGGLE_PREVIEW_KEY="ctrl-y" gh notify +``` + **NOTE:** The assigned key must be a valid key listed in the `fzf` man page: ```sh