Skip to content

Commit 55ea798

Browse files
authored
fix: correct unread notification marking logic (#90)
* fix: correct unread notification marking logic * feat: add toggle key for notifications * docs: Update GH_NOTIFY_FZF_OPTS description and usage * style: improve code structure and comments * chore: Improve notification marking and debugging * chore: cleanup mark_individual_read function * docs: add fzf height example in README
1 parent de732ab commit 55ea798

File tree

2 files changed

+172
-98
lines changed

2 files changed

+172
-98
lines changed

gh-notify

+149-98
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,31 @@
22
set -o errexit -o nounset -o pipefail
33
# https://www.gnu.org/software/bash/manual/bash.html#The-Set-Builtin
44

5-
# ====================== Infos =======================
5+
###############################################################################
6+
# Information
7+
###############################################################################
68

79
# https://docs.github.com/en/rest/activity/notifications
10+
# https://docs.github.com/en/graphql/reference/queries
811
# NotificationReason:
912
# assign, author, comment, invitation, manual, mention, review_requested, security_alert, state_change, subscribed, team_mention, ci_activity
1013
# NotificationSubjectTypes:
1114
# CheckSuite, Commit, Discussion, Issue, PullRequest, Release, RepositoryVulnerabilityAlert, ...
1215

13-
# ====================== set variables =======================
16+
###############################################################################
17+
# Set Variables
18+
###############################################################################
1419

15-
# The minimum fzf version that the user needs to run all interactive commands.
16-
MIN_FZF_VERSION="0.29.0"
17-
18-
# export variables for use in child processes
20+
# Export variables for use in child processes.
21+
set -o allexport
1922
# https://docs.github.com/en/rest/overview/api-versions
20-
export GH_REST_API_VERSION="X-GitHub-Api-Version:2022-11-28"
23+
GH_REST_API_VERSION="X-GitHub-Api-Version:2022-11-28"
2124
# Enable terminal-style output even when the output is redirected.
22-
export GH_FORCE_TTY=1
25+
# shellcheck disable=SC2034
26+
GH_FORCE_TTY=1
27+
# The maximum number of notifications per page set by GitHub.
28+
GH_NOTIFY_PER_PAGE_LIMIT=50
2329

24-
# Need to be exported because of its use in the 'print_help_text' function
25-
set -o allexport
2630
# Customize the fzf keys using environment variables
2731
: "${GH_NOTIFY_MARK_ALL_READ_KEY:=ctrl-a}"
2832
: "${GH_NOTIFY_OPEN_BROWSER_KEY:=ctrl-b}"
@@ -31,16 +35,51 @@ set -o allexport
3135
: "${GH_NOTIFY_RELOAD_KEY:=ctrl-r}"
3236
: "${GH_NOTIFY_MARK_READ_KEY:=ctrl-t}"
3337
: "${GH_NOTIFY_COMMENT_KEY:=ctrl-x}"
38+
: "${GH_NOTIFY_TOGGLE_KEY:=ctrl-y}"
3439
: "${GH_NOTIFY_RESIZE_PREVIEW_KEY:=btab}"
3540
: "${GH_NOTIFY_VIEW_KEY:=enter}"
3641
: "${GH_NOTIFY_TOGGLE_PREVIEW_KEY:=tab}"
3742
: "${GH_NOTIFY_TOGGLE_HELP_KEY:=?}"
38-
set +o allexport
3943

40-
# The maximum number of notifications per page (set by GitHub)
41-
export GH_NOTIFY_PER_PAGE_LIMIT=50
4244
# Assign 'GH_NOTIFY_DEBUG_MODE' with 'true' to see more information
43-
export GH_NOTIFY_DEBUG_MODE=${GH_NOTIFY_DEBUG_MODE:-false}
45+
: "${GH_NOTIFY_DEBUG_MODE:=false}"
46+
47+
# 'SHLVL' variable represents the nesting level of the current shell
48+
NESTED_START_LVL="$SHLVL"
49+
FINAL_MSG='All caught up!'
50+
51+
# color codes
52+
GREEN='\033[0;32m'
53+
DARK_GRAY='\033[0;90m'
54+
NC='\033[0m'
55+
WHITE_BOLD='\033[1m'
56+
57+
exclusion_string='XXX_BOGUS_STRING_THAT_SHOULD_NOT_EXIST_XXX'
58+
filter_string=''
59+
num_notifications=0
60+
only_participating_flag=false
61+
include_all_flag=false
62+
preview_window_visibility='hidden'
63+
python_executable=''
64+
set +o allexport
65+
66+
# No need to export, since they aren't used in any child process.
67+
print_static_flag=false
68+
mark_read_flag=false
69+
update_subscription_url=''
70+
71+
# The minimum fzf version that the user needs to run all interactive commands.
72+
MIN_FZF_VERSION="0.29.0"
73+
74+
###############################################################################
75+
# Debugging and Error Handling Configuration
76+
###############################################################################
77+
78+
die() {
79+
echo ERROR: "$*" >&2
80+
exit 1
81+
}
82+
4483
if $GH_NOTIFY_DEBUG_MODE; then
4584
export gh_notify_debug_log="${BASH_SOURCE[0]%/*}/gh_notify_debug.log"
4685

@@ -67,6 +106,16 @@ if $GH_NOTIFY_DEBUG_MODE; then
67106
# Redirect possible errors and debug information from 'gh api' calls to a file
68107
# exec 5> >(tee -a "$gh_notify_debug_log")
69108

109+
# Ensure Bash 4.1+ for BASH_XTRACEFD support.
110+
if [[ ${BASH_VERSINFO[0]} -lt 4 || (${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 1) ]]; then
111+
die "Bash 4.1 or newer is required for debugging. Current version: ${BASH_VERSION}"
112+
fi
113+
114+
# Ensure fzf 0.51.0+ for '--with-shell' support.
115+
MIN_FZF_VERSION="0.51.0"
116+
# Ensure xtrace is enabled in all child processes started by 'fzf'.
117+
FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS-} --with-shell \"$(which bash) -o xtrace -o nounset -o pipefail -c\""
118+
70119
# Redirect xtrace output to a file
71120
exec 6>>"$gh_notify_debug_log"
72121
# Write the trace output to file descriptor 6
@@ -75,36 +124,11 @@ if $GH_NOTIFY_DEBUG_MODE; then
75124
export PS4='+$(date +%Y-%m-%d:%H:%M:%S) ${FUNCNAME[0]:-}:L${LINENO:-}: '
76125
set -o xtrace
77126
fi
78-
# 'SHLVL' variable represents the nesting level of the current shell
79-
export NESTED_START_LVL="$SHLVL"
80-
export FINAL_MSG='All caught up!'
81-
82-
# color codes
83-
export GREEN='\033[0;32m'
84-
export DARK_GRAY='\033[0;90m'
85-
export NC='\033[0m'
86-
export WHITE_BOLD='\033[1m'
87-
88-
export exclusion_string='XXX_BOGUS_STRING_THAT_SHOULD_NOT_EXIST_XXX'
89-
export filter_string=''
90-
export num_notifications=0
91-
export only_participating_flag=false
92-
export include_all_flag=false
93-
export preview_window_visibility='hidden'
94-
export python_executable=''
95-
# not necessarily to be exported, since they are not used in any child process
96-
print_static_flag=false
97-
mark_read_flag=false
98-
update_subscription_url=''
99127

100-
# ===================== basic functions =====================
101-
102-
die() {
103-
echo ERROR: "$*" >&2
104-
exit 1
105-
}
128+
###############################################################################
129+
# Helper Functions
130+
###############################################################################
106131

107-
# Create help message with colored text
108132
# IMPORTANT: Keep it synchronized with the README, but without the Examples.
109133
print_help_text() {
110134
local help_text
@@ -139,6 +163,7 @@ ${WHITE_BOLD}Key Bindings fzf${NC}
139163
${GREEN}${GH_NOTIFY_RELOAD_KEY} ${NC} reload
140164
${GREEN}${GH_NOTIFY_MARK_READ_KEY} ${NC} mark the selected notification as read and reload
141165
${GREEN}${GH_NOTIFY_COMMENT_KEY} ${NC} write a comment with the editor and quit
166+
${GREEN}${GH_NOTIFY_TOGGLE_KEY} ${NC} toggle the selected notification
142167
${GREEN}esc ${NC} quit
143168
144169
${WHITE_BOLD}Table Format${NC}
@@ -158,37 +183,6 @@ EOF
158183
echo -e "$help_text"
159184
}
160185

161-
# ====================== parse command-line options =======================
162-
163-
while getopts 'e:f:n:u:pawhsr' flag; do
164-
case "${flag}" in
165-
e)
166-
FINAL_MSG="No results found."
167-
exclusion_string="${OPTARG}"
168-
;;
169-
f)
170-
FINAL_MSG="No results found."
171-
filter_string="${OPTARG}"
172-
;;
173-
n) num_notifications="${OPTARG}" ;;
174-
p) only_participating_flag=true ;;
175-
u) update_subscription_url="${OPTARG}" ;;
176-
a) include_all_flag=true ;;
177-
w) preview_window_visibility='nohidden' ;;
178-
s) print_static_flag=true ;;
179-
r) mark_read_flag=true ;;
180-
h)
181-
print_help_text
182-
exit 0
183-
;;
184-
*)
185-
die "see 'gh notify -h' for help"
186-
;;
187-
esac
188-
done
189-
190-
# ===================== helper functions ==========================
191-
192186
gh_rest_api() {
193187
command gh api --header "$GH_REST_API_VERSION" --method GET --cache=0s "$@"
194188
}
@@ -503,6 +497,10 @@ view_in_pager() {
503497
view_notification --all_comments "$1" | command less "${less_args[@]}" >/dev/tty
504498
}
505499

500+
# Use this only when the list isn't filtered to avoid marking not displayed notifications as read.
501+
# Check if the 'fzf' query or '-e' (exclude) or '-f' (filter) flags were used by examining
502+
# the emptiness of '{q}' and any changes to `FINAL_MSG`, specifically if it remains "All caught up".
503+
# TODO: The 2nd check is hacky; seek a cleaner solution with minimal code addition.
506504
mark_all_read() {
507505
local iso_time
508506
IFS=' ' read -r iso_time _ <<<"$1"
@@ -513,9 +511,30 @@ mark_all_read() {
513511

514512
mark_individual_read() {
515513
local thread_id thread_state
516-
IFS=' ' read -r _ thread_id thread_state _ <<<"$1"
517-
if [[ $thread_state == "UNREAD" ]]; then
518-
gh_rest_api --silent --method PATCH "notifications/threads/${thread_id}"
514+
declare -a array_threads=()
515+
while IFS=' ' read -r _ thread_id thread_state _; do
516+
if [[ $thread_state == "UNREAD" ]]; then
517+
array_threads+=("$thread_id")
518+
fi
519+
done <"$1"
520+
521+
if [[ ${#array_threads[@]} -eq 1 ]]; then
522+
gh_rest_api --silent --method PATCH "notifications/threads/${array_threads[0]}" ||
523+
die "Failed to mark notifications as read."
524+
elif [[ ${#array_threads[@]} -gt 1 ]]; then
525+
# If there is a large number of threads to be processed, the number of background jobs can
526+
# put pressure on the PC. Additionally, too many requests in short succession can trigger a
527+
# rate limit by GitHub. Therefore, we process the threads in batches of 30, with a short
528+
# delay of 0.3 seconds between each batch. This approach worked well in my tests with 200
529+
# notifications.
530+
for ((i = 0; i < ${#array_threads[@]}; i += 30)); do
531+
for j in "${array_threads[@]:i:30}"; do
532+
# Running commands in the background of a script can cause it to hang, especially if
533+
# the command outputs to stdout: https://tldp.org/LDP/abs/html/x9644.html#WAITHANG
534+
gh_rest_api --silent --method PATCH "notifications/threads/${j}" &>/dev/null &
535+
done
536+
command sleep 0.3
537+
done
519538
fi
520539
}
521540

@@ -531,20 +550,18 @@ select_notif() {
531550
# a failed 'print_notifs' call, but does not display the message.
532551

533552
# See the man page (man fzf) for an explanation of the arguments.
534-
# '--print-query' and '--delimiter' are not strictly needed here,
535-
# but a user could have them in their ‘FZF_DEFAULT_OPTS’
536-
# and so the lines would get screwed up and fail if we don't take that into account.
537553
output=$(
538-
SHELL="$(which bash)" command fzf \
554+
SHELL="$(which bash)" FZF_DEFAULT_OPTS="${FZF_DEFAULT_OPTS-} ${GH_NOTIFY_FZF_OPTS-}" command fzf \
539555
--ansi \
540556
--bind "${GH_NOTIFY_RESIZE_PREVIEW_KEY}:change-preview-window(75%:nohidden|75%:down:nohidden:border-top|nohidden)" \
541557
--bind "change:first" \
542-
--bind "${GH_NOTIFY_MARK_ALL_READ_KEY}:execute-silent(mark_all_read {})+reload:print_notifs || true" \
558+
--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" \
543559
--bind "${GH_NOTIFY_OPEN_BROWSER_KEY}:execute-silent:open_in_browser {}" \
544560
--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" \
545561
--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" \
546562
--bind "${GH_NOTIFY_RELOAD_KEY}:reload:print_notifs || true" \
547-
--bind "${GH_NOTIFY_MARK_READ_KEY}:execute-silent(mark_individual_read {})+reload:print_notifs || true" \
563+
--bind "${GH_NOTIFY_MARK_READ_KEY}:execute-silent(mark_individual_read {+f})+reload:print_notifs || true" \
564+
--bind "${GH_NOTIFY_TOGGLE_KEY}:toggle+down" \
548565
--bind "${GH_NOTIFY_VIEW_KEY}:execute:view_in_pager {}" \
549566
--bind "${GH_NOTIFY_TOGGLE_PREVIEW_KEY}:toggle-preview+change-preview:view_notification {}" \
550567
--bind "${GH_NOTIFY_TOGGLE_HELP_KEY}:toggle-preview+change-preview:print_help_text" \
@@ -556,21 +573,23 @@ select_notif() {
556573
--expect "esc,${GH_NOTIFY_COMMENT_KEY}" \
557574
--header "${GH_NOTIFY_TOGGLE_HELP_KEY} help · esc quit" \
558575
--info=inline \
559-
--no-multi \
576+
--multi \
560577
--pointer="" \
561578
--preview "view_notification {}" \
562579
--preview-window "default:wrap:${preview_window_visibility}:60%:right:border-left" \
563-
--print-query \
580+
--no-print-query \
564581
--prompt "GitHub Notifications > " \
565582
--reverse \
566583
--with-nth 6.. <<<"$1"
567584
)
568585
# actions that close fzf are defined below
569-
# 1st line ('--print-query'): the input query string
570-
# 2nd line ('--expect'): the actual key
571-
# 3rd line: the selected line when the user pressed the key
572-
expected_key="$(command sed '1d;3d' <<<"$output")"
573-
selected_line="$(command sed '1d;2d' <<<"$output")"
586+
# 1st line ('--expect'): the actual key
587+
# 2nd line: the selected line when the user pressed the key
588+
expected_key="$(command sed q <<<"$output")"
589+
selected_line="$(command sed '1d' <<<"$output")"
590+
if [[ $(sed -n '$=' <<<"$selected_line") -gt 1 && $expected_key != "esc" ]]; then
591+
die "Please select only one notification for this operation."
592+
fi
574593
IFS=' ' read -r _ thread_id thread_state _ repo_full_name _ _ _ _ type num _ <<<"$selected_line"
575594
[[ -z $type ]] && exit 0
576595
case "$expected_key" in
@@ -581,7 +600,8 @@ select_notif() {
581600
"${GH_NOTIFY_COMMENT_KEY}")
582601
if command grep -qE "Issue|PullRequest" <<<"$type"; then
583602
command gh issue comment "$num" --repo "$repo_full_name"
584-
mark_individual_read "$selected_line" || die "Failed to mark the notification as read."
603+
# The function requires input in a file-like format
604+
mark_individual_read <(echo "$selected_line")
585605
else
586606
printf "Writing comments is only supported for %bIssues%b and %bPullRequests%b.\n" \
587607
"$WHITE_BOLD" "$NC" "$WHITE_BOLD" "$NC"
@@ -678,8 +698,35 @@ update_subscription() {
678698
fi
679699
}
680700

681-
gh_notify() {
701+
main() {
682702
local python_version notifs
703+
# CLI Options
704+
while getopts 'e:f:n:u:pawsrh' flag; do
705+
case "${flag}" in
706+
e)
707+
FINAL_MSG="No results found."
708+
exclusion_string="${OPTARG}"
709+
;;
710+
f)
711+
FINAL_MSG="No results found."
712+
filter_string="${OPTARG}"
713+
;;
714+
n) num_notifications="${OPTARG}" ;;
715+
p) only_participating_flag=true ;;
716+
u) update_subscription_url="${OPTARG}" ;;
717+
a) include_all_flag=true ;;
718+
w) preview_window_visibility='nohidden' ;;
719+
s) print_static_flag=true ;;
720+
r) mark_read_flag=true ;;
721+
h)
722+
print_help_text
723+
exit 0
724+
;;
725+
*)
726+
die "see 'gh notify -h' for help"
727+
;;
728+
esac
729+
done
683730

684731
if ! command -v gh >/dev/null; then
685732
die "install 'gh'"
@@ -690,8 +737,12 @@ gh_notify() {
690737
fi
691738

692739
if $mark_read_flag; then
693-
mark_all_read "" || die "Failed to mark notifications as read."
694-
echo "All notifications have been marked as read."
740+
if [[ $FINAL_MSG =~ 'All caught up' ]]; then
741+
mark_all_read "" || die "Failed to mark notifications as read."
742+
echo "All notifications have been marked as read."
743+
else
744+
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."
745+
fi
695746
exit 0
696747
fi
697748

@@ -709,7 +760,6 @@ gh_notify() {
709760
if ! command -v fzf >/dev/null; then
710761
die "install 'fzf' or use the -s flag"
711762
fi
712-
713763
check_version fzf "$MIN_FZF_VERSION"
714764
fi
715765

@@ -726,7 +776,8 @@ gh_notify() {
726776
fi
727777
}
728778

729-
# This will call the function only when the script is run, not when it's sourced
730-
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
731-
gh_notify
732-
fi
779+
###############################################################################
780+
# Script Execution
781+
###############################################################################
782+
783+
main "$@"

0 commit comments

Comments
 (0)