2
2
set -o errexit -o nounset -o pipefail
3
3
# https://www.gnu.org/software/bash/manual/bash.html#The-Set-Builtin
4
4
5
- # ====================== Infos =======================
5
+ # ##############################################################################
6
+ # Information
7
+ # ##############################################################################
6
8
7
9
# https://docs.github.com/en/rest/activity/notifications
10
+ # https://docs.github.com/en/graphql/reference/queries
8
11
# NotificationReason:
9
12
# assign, author, comment, invitation, manual, mention, review_requested, security_alert, state_change, subscribed, team_mention, ci_activity
10
13
# NotificationSubjectTypes:
11
14
# CheckSuite, Commit, Discussion, Issue, PullRequest, Release, RepositoryVulnerabilityAlert, ...
12
15
13
- # ====================== set variables =======================
16
+ # ##############################################################################
17
+ # Set Variables
18
+ # ##############################################################################
14
19
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
19
22
# 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"
21
24
# 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
23
29
24
- # Need to be exported because of its use in the 'print_help_text' function
25
- set -o allexport
26
30
# Customize the fzf keys using environment variables
27
31
: " ${GH_NOTIFY_MARK_ALL_READ_KEY:= ctrl-a} "
28
32
: " ${GH_NOTIFY_OPEN_BROWSER_KEY:= ctrl-b} "
@@ -31,16 +35,51 @@ set -o allexport
31
35
: " ${GH_NOTIFY_RELOAD_KEY:= ctrl-r} "
32
36
: " ${GH_NOTIFY_MARK_READ_KEY:= ctrl-t} "
33
37
: " ${GH_NOTIFY_COMMENT_KEY:= ctrl-x} "
38
+ : " ${GH_NOTIFY_TOGGLE_KEY:= ctrl-y} "
34
39
: " ${GH_NOTIFY_RESIZE_PREVIEW_KEY:= btab} "
35
40
: " ${GH_NOTIFY_VIEW_KEY:= enter} "
36
41
: " ${GH_NOTIFY_TOGGLE_PREVIEW_KEY:= tab} "
37
42
: " ${GH_NOTIFY_TOGGLE_HELP_KEY:= ?} "
38
- set +o allexport
39
43
40
- # The maximum number of notifications per page (set by GitHub)
41
- export GH_NOTIFY_PER_PAGE_LIMIT=50
42
44
# 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
+
44
83
if $GH_NOTIFY_DEBUG_MODE ; then
45
84
export gh_notify_debug_log=" ${BASH_SOURCE[0]%/* } /gh_notify_debug.log"
46
85
@@ -67,6 +106,16 @@ if $GH_NOTIFY_DEBUG_MODE; then
67
106
# Redirect possible errors and debug information from 'gh api' calls to a file
68
107
# exec 5> >(tee -a "$gh_notify_debug_log")
69
108
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
+
70
119
# Redirect xtrace output to a file
71
120
exec 6>> " $gh_notify_debug_log "
72
121
# Write the trace output to file descriptor 6
@@ -75,36 +124,11 @@ if $GH_NOTIFY_DEBUG_MODE; then
75
124
export PS4=' +$(date +%Y-%m-%d:%H:%M:%S) ${FUNCNAME[0]:-}:L${LINENO:-}: '
76
125
set -o xtrace
77
126
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=' '
99
127
100
- # ===================== basic functions =====================
101
-
102
- die () {
103
- echo ERROR: " $* " >&2
104
- exit 1
105
- }
128
+ # ##############################################################################
129
+ # Helper Functions
130
+ # ##############################################################################
106
131
107
- # Create help message with colored text
108
132
# IMPORTANT: Keep it synchronized with the README, but without the Examples.
109
133
print_help_text () {
110
134
local help_text
@@ -139,6 +163,7 @@ ${WHITE_BOLD}Key Bindings fzf${NC}
139
163
${GREEN}${GH_NOTIFY_RELOAD_KEY} ${NC} reload
140
164
${GREEN}${GH_NOTIFY_MARK_READ_KEY} ${NC} mark the selected notification as read and reload
141
165
${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
142
167
${GREEN} esc ${NC} quit
143
168
144
169
${WHITE_BOLD} Table Format${NC}
158
183
echo -e " $help_text "
159
184
}
160
185
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
-
192
186
gh_rest_api () {
193
187
command gh api --header " $GH_REST_API_VERSION " --method GET --cache=0s " $@ "
194
188
}
@@ -503,6 +497,10 @@ view_in_pager() {
503
497
view_notification --all_comments " $1 " | command less " ${less_args[@]} " > /dev/tty
504
498
}
505
499
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.
506
504
mark_all_read () {
507
505
local iso_time
508
506
IFS=' ' read -r iso_time _ <<< " $1"
@@ -513,9 +511,30 @@ mark_all_read() {
513
511
514
512
mark_individual_read () {
515
513
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
519
538
fi
520
539
}
521
540
@@ -531,20 +550,18 @@ select_notif() {
531
550
# a failed 'print_notifs' call, but does not display the message.
532
551
533
552
# 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.
537
553
output=$(
538
- SHELL=" $( which bash) " command fzf \
554
+ SHELL=" $( which bash) " FZF_DEFAULT_OPTS= " ${FZF_DEFAULT_OPTS-} ${GH_NOTIFY_FZF_OPTS-} " command fzf \
539
555
--ansi \
540
556
--bind " ${GH_NOTIFY_RESIZE_PREVIEW_KEY} :change-preview-window(75%:nohidden|75%:down:nohidden:border-top|nohidden)" \
541
557
--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" \
543
559
--bind " ${GH_NOTIFY_OPEN_BROWSER_KEY} :execute-silent:open_in_browser {}" \
544
560
--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" \
545
561
--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" \
546
562
--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" \
548
565
--bind " ${GH_NOTIFY_VIEW_KEY} :execute:view_in_pager {}" \
549
566
--bind " ${GH_NOTIFY_TOGGLE_PREVIEW_KEY} :toggle-preview+change-preview:view_notification {}" \
550
567
--bind " ${GH_NOTIFY_TOGGLE_HELP_KEY} :toggle-preview+change-preview:print_help_text" \
@@ -556,21 +573,23 @@ select_notif() {
556
573
--expect " esc,${GH_NOTIFY_COMMENT_KEY} " \
557
574
--header " ${GH_NOTIFY_TOGGLE_HELP_KEY} help · esc quit" \
558
575
--info=inline \
559
- --no- multi \
576
+ --multi \
560
577
--pointer=" ▶" \
561
578
--preview " view_notification {}" \
562
579
--preview-window " default:wrap:${preview_window_visibility} :60%:right:border-left" \
563
- --print-query \
580
+ --no- print-query \
564
581
--prompt " GitHub Notifications > " \
565
582
--reverse \
566
583
--with-nth 6.. <<< " $1"
567
584
)
568
585
# 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
574
593
IFS=' ' read -r _ thread_id thread_state _ repo_full_name _ _ _ _ type num _ <<< " $selected_line"
575
594
[[ -z $type ]] && exit 0
576
595
case " $expected_key " in
@@ -581,7 +600,8 @@ select_notif() {
581
600
" ${GH_NOTIFY_COMMENT_KEY} " )
582
601
if command grep -qE " Issue|PullRequest" <<< " $type" ; then
583
602
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 " )
585
605
else
586
606
printf " Writing comments is only supported for %bIssues%b and %bPullRequests%b.\n" \
587
607
" $WHITE_BOLD " " $NC " " $WHITE_BOLD " " $NC "
@@ -678,8 +698,35 @@ update_subscription() {
678
698
fi
679
699
}
680
700
681
- gh_notify () {
701
+ main () {
682
702
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
683
730
684
731
if ! command -v gh > /dev/null; then
685
732
die " install 'gh'"
@@ -690,8 +737,12 @@ gh_notify() {
690
737
fi
691
738
692
739
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
695
746
exit 0
696
747
fi
697
748
@@ -709,7 +760,6 @@ gh_notify() {
709
760
if ! command -v fzf > /dev/null; then
710
761
die " install 'fzf' or use the -s flag"
711
762
fi
712
-
713
763
check_version fzf " $MIN_FZF_VERSION "
714
764
fi
715
765
@@ -726,7 +776,8 @@ gh_notify() {
726
776
fi
727
777
}
728
778
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