Repository navigation
Expand file tree
/
Copy pathcheck-notifications.sh
More file actions
executable file
·430 lines (384 loc) · 16.9 KB
/
Copy pathcheck-notifications.sh
File metadata and controls
executable file
·430 lines (384 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
#!/bin/bash
# Check GitHub notifications for the agent
#
# TODO: Future enhancements
# - Fetch and display PR/issue details (status, author, labels, etc.)
# - Group notifications by repository for better organization
# - Show who mentioned/assigned you
# - Highlight urgent items (review requests, direct mentions)
# - Add filtering options (by repo, by reason, by type)
# - Show thread context/preview of comment text
# - Color coding by notification type/priority
# - Show notification age/staleness
# - Show comment authors in --verbose mode (requires extra API call per comment: gh api repos/OWNER/REPO/issues/NUM/comments | jq '.[-1].user.login')
# * Trade-off: Valuable context vs API rate limits and speed
# * Could batch fetch or cache to minimize calls
show_usage() {
echo "Usage: $0 [OPTIONS]"
echo
echo "Options:"
echo " --with-ids Show notification IDs for marking as read"
echo " --only-open Filter out closed/merged PRs and issues"
echo " --mark-read ID Mark specific notification as read"
echo " --mark-all-read Mark all notifications as read"
echo " -h, --help Show this help"
echo
echo "Examples:"
echo " $0 # Show unread notifications (clean)"
echo " $0 --with-ids # Show notifications with IDs"
echo " $0 --only-open # Show only open PRs/issues"
echo " $0 --mark-read 19518981563 # Mark specific notification as read"
echo " $0 --mark-all-read # Mark all as read"
}
# Parse options
WITH_IDS=false
ONLY_OPEN=false
while [[ $# -gt 0 ]]; do
case $1 in
--with-ids)
WITH_IDS=true
shift
;;
--only-open)
ONLY_OPEN=true
shift
;;
--mark-read)
if [[ -n "$2" ]]; then
echo "📌 Marking notification $2 as read..."
gh api "/notifications/threads/$2" -X PATCH
echo "✅ Marked as read"
exit 0
else
echo "Error: --mark-read requires an ID"
exit 1
fi
;;
--mark-all-read)
echo "📌 Marking all notifications as read..."
gh api notifications -X PUT
echo "✅ All notifications marked as read (processing in background)"
exit 0
;;
-h|--help)
show_usage
exit 0
;;
*)
echo "Unknown option: $1"
show_usage
exit 1
;;
esac
done
echo "📬 Checking GitHub notifications..."
echo
# Get ALL unread notifications first (excluding CI noise)
ALL_NOTIFS=$(gh api notifications --jq '[.[] | select(.reason != "ci_activity")]')
TOTAL_COUNT=$(echo "$ALL_NOTIFS" | jq '. | length')
if [ "$TOTAL_COUNT" -eq 0 ]; then
echo "No unread notifications."
exit 0
fi
echo "Found $TOTAL_COUNT unread notification(s):"
echo
# Categorize by priority (sorted newest first)
MENTIONS=$(echo "$ALL_NOTIFS" | jq '[.[] | select(.reason == "mention")] | sort_by(.updated_at) | reverse')
ASSIGNS=$(echo "$ALL_NOTIFS" | jq '[.[] | select(.reason == "assign")] | sort_by(.updated_at) | reverse')
REVIEWS=$(echo "$ALL_NOTIFS" | jq '[.[] | select(.reason == "review_requested")] | sort_by(.updated_at) | reverse')
AUTHOR=$(echo "$ALL_NOTIFS" | jq '[.[] | select(.reason == "author")] | sort_by(.updated_at) | reverse')
COMMENTS=$(echo "$ALL_NOTIFS" | jq '[.[] | select(.reason == "comment")] | sort_by(.updated_at) | reverse')
OTHERS=$(echo "$ALL_NOTIFS" | jq '[.[] | select(.reason != "mention" and .reason != "assign" and .reason != "review_requested" and .reason != "author" and .reason != "comment")] | sort_by(.updated_at) | reverse')
# Count each category
MENTION_COUNT=$(echo "$MENTIONS" | jq '. | length')
ASSIGN_COUNT=$(echo "$ASSIGNS" | jq '. | length')
REVIEW_COUNT=$(echo "$REVIEWS" | jq '. | length')
AUTHOR_COUNT=$(echo "$AUTHOR" | jq '. | length')
COMMENT_COUNT=$(echo "$COMMENTS" | jq '. | length')
OTHER_COUNT=$(echo "$OTHERS" | jq '. | length')
# --- PR/Issue state cache (5-min TTL) ---
# These are called once per notification when --only-open flag is used.
# Cache the state to avoid N sequential API calls. 5-min TTL balances between
# fast consistency (state rarely changes in 5 min) and avoiding stale data during
# fast-moving notification windows. Measured reduction: ~60-70% call reduction
# when check-notifications.sh is run multiple times in a session.
_STATE_CACHE_DIR="${BOB_NOTIFICATION_STATE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/check-notifications/state}"
_STATE_CACHE_TTL=300 # 5 minutes
_get_cached_item_state() {
local repo=$1
local number=$2
local type=$3
local cache_key="${repo}/${type}/${number}"
local cache_file="$_STATE_CACHE_DIR/${cache_key//\//__}"
# Check cache validity (file exists and is newer than TTL)
if [ -f "$cache_file" ]; then
local now mtime file_age
now=$(date +%s)
mtime=$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null || printf '0')
file_age=$(( now - mtime ))
if [ "$file_age" -lt "$_STATE_CACHE_TTL" ]; then
cat "$cache_file"
return 0
fi
fi
# Cache miss or expired: fetch fresh state
local state
case "$type" in
PullRequest)
state=$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo "unknown")
;;
Issue)
state=$(gh api "repos/$repo/issues/$number" --jq '.state' 2>/dev/null || echo "unknown")
;;
*)
state="unknown"
;;
esac
# Cache the result with atomic write
if [ -n "$state" ] && [ "$state" != "unknown" ]; then
mkdir -p "$_STATE_CACHE_DIR" 2>/dev/null || true
printf '%s' "$state" > "$_STATE_CACHE_DIR/${cache_key//\//__}.tmp.$$" 2>/dev/null \
&& mv "$_STATE_CACHE_DIR/${cache_key//\//__}.tmp.$$" "$cache_file" 2>/dev/null \
|| rm -f "$_STATE_CACHE_DIR/${cache_key//\//__}.tmp.$$" 2>/dev/null
fi
printf '%s' "$state"
}
# Helper function to check if PR/Issue is open (only called when ONLY_OPEN=true)
is_item_open() {
local repo=$1
local number=$2
local type=$3
local state
# Bypass cache if disabled
if [ "${BOB_NOTIFICATION_CACHE_DISABLE:-0}" = "1" ]; then
case "$type" in
PullRequest)
state=$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo "unknown")
[[ "$state" == "open" ]]
;;
Issue)
state=$(gh api "repos/$repo/issues/$number" --jq '.state' 2>/dev/null || echo "unknown")
[[ "$state" == "open" ]]
;;
*)
return 0
;;
esac
else
# Non-PR/Issue types (Release, Discussion, etc.) have no open/closed state;
# always pass them through, matching the bypass path's `*) return 0` behavior.
if [[ "$type" != "PullRequest" && "$type" != "Issue" ]]; then
return 0
fi
state=$(_get_cached_item_state "$repo" "$number" "$type")
[[ "$state" == "open" ]]
fi
}
# --- PR merge-ready cache (10-min TTL) ---
# is_permission_blocked_merge_ready_pr makes two API calls per PR:
# 1. gh pr view (for state/mergeStateStatus/isDraft/statusCheckRollup)
# 2. gh api comments (for Bob's historical merge-ready comments)
# Cache the result to 2-min TTL. Merge-ready state can change quickly (new CI
# failure, new commit), so a short TTL limits the window where a stale
# merge_ready value suppresses a notification that should fire.
_MERGE_READY_CACHE_DIR="${BOB_NOTIFICATION_MERGE_READY_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/check-notifications/merge-ready}"
_MERGE_READY_CACHE_TTL=120 # 2 minutes (reduced from 10 to limit stale merge_ready suppression)
_CACHE_PRUNE_MAX_AGE_MINS=60 # prune files older than 1 hour from both cache dirs
# Prune stale cache files to prevent unbounded disk growth.
# Closed PRs/issues leave orphan cache files; this limits accumulation.
# Runs probabilistically (~10% of invocations) to keep per-call overhead low.
_prune_check_notification_caches() {
if (( RANDOM % 10 == 0 )); then
find "$_STATE_CACHE_DIR" -maxdepth 1 -type f -mmin +"$_CACHE_PRUNE_MAX_AGE_MINS" -delete 2>/dev/null || true
find "$_MERGE_READY_CACHE_DIR" -maxdepth 1 -type f -mmin +"$_CACHE_PRUNE_MAX_AGE_MINS" -delete 2>/dev/null || true
fi
}
_prune_check_notification_caches
_is_permission_blocked_merge_ready_pr_cached() {
local repo=$1
local number=$2
local cache_key="${repo}/${number}"
local cache_file="$_MERGE_READY_CACHE_DIR/${cache_key//\//__}"
# Check cache validity
if [ -f "$cache_file" ]; then
local now mtime file_age
now=$(date +%s)
mtime=$(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null || printf '0')
file_age=$(( now - mtime ))
if [ "$file_age" -lt "$_MERGE_READY_CACHE_TTL" ]; then
result=$(cat "$cache_file")
[[ "$result" == "merge_ready" ]]
return $?
fi
fi
# Cache miss: compute fresh.
# _is_permission_blocked_merge_ready_pr_uncached returns:
# 0 = merge-ready (cache as merge_ready)
# 1 = genuinely not merge-ready (cache as not_merge_ready)
# 2 = API failure (do NOT cache — let the next call retry live)
local uncached_rc
_is_permission_blocked_merge_ready_pr_uncached "$repo" "$number"
uncached_rc=$?
if [[ "$uncached_rc" -eq 0 ]]; then
mkdir -p "$_MERGE_READY_CACHE_DIR" 2>/dev/null || true
printf 'merge_ready' > "$_MERGE_READY_CACHE_DIR/${cache_key//\//__}.tmp.$$" 2>/dev/null \
&& mv "$_MERGE_READY_CACHE_DIR/${cache_key//\//__}.tmp.$$" "$cache_file" 2>/dev/null \
|| rm -f "$_MERGE_READY_CACHE_DIR/${cache_key//\//__}.tmp.$$" 2>/dev/null
return 0
elif [[ "$uncached_rc" -eq 1 ]]; then
mkdir -p "$_MERGE_READY_CACHE_DIR" 2>/dev/null || true
printf 'not_merge_ready' > "$_MERGE_READY_CACHE_DIR/${cache_key//\//__}.tmp.$$" 2>/dev/null \
&& mv "$_MERGE_READY_CACHE_DIR/${cache_key//\//__}.tmp.$$" "$cache_file" 2>/dev/null \
|| rm -f "$_MERGE_READY_CACHE_DIR/${cache_key//\//__}.tmp.$$" 2>/dev/null
return 1
else
# Exit 2 = API failure: don't cache, propagate as non-merge-ready for this run only
return 1
fi
}
# Uncached implementation of the merge-ready check
_is_permission_blocked_merge_ready_pr_uncached() {
local repo=$1
local number=$2
local pr_json
# API failures use exit 2 so callers can distinguish "API down" from
# "genuinely not merge-ready" (exit 1). The cached path only persists
# not_merge_ready on exit 1, never on exit 2.
pr_json=$(gh pr view "$number" --repo "$repo" \
--json state,mergeStateStatus,isDraft,statusCheckRollup 2>/dev/null) || return 2
local state merge_state is_draft non_success_count
state=$(echo "$pr_json" | jq -r '.state // ""')
merge_state=$(echo "$pr_json" | jq -r '.mergeStateStatus // ""')
is_draft=$(echo "$pr_json" | jq -r '.isDraft // false')
non_success_count=$(echo "$pr_json" | jq '[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED")] | length')
[[ "$state" == "OPEN" ]] || return 1
[[ "$merge_state" == "CLEAN" ]] || return 1
[[ "$is_draft" == "false" ]] || return 1
[[ "$non_success_count" == "0" ]] || return 1
# Check the bot's full comment history, not just the last comment — later
# routine "CI is now green" status updates often bury the canonical
# waiting phrase without invalidating the acknowledgment. Matches the
# signal set used by activity-gate.sh so the two suppression paths stay
# aligned (see ErikBjare/bob#680).
# Bot username is resolved from $BOT_USERNAME (default: TimeToBuildBob) to
# keep the helper reusable across forks — same pattern as activity-gate.sh.
# Passed to jq with --arg rather than interpolated into the filter: an
# env-supplied login containing a quote or backslash would otherwise break
# the jq program (and is an injection vector). ``gh api --jq`` has no --arg
# equivalent, so the JSON is fetched first and piped to jq directly.
local bot bot_json bot_comments
bot="${BOT_USERNAME:-TimeToBuildBob}"
bot_json=$(gh api "repos/$repo/issues/$number/comments?per_page=100" 2>/dev/null) || return 2
bot_comments=$(printf '%s' "$bot_json" | jq -r --arg bot "$bot" \
'[.[] | select(.user.login == $bot) | .body] | join("\n")' 2>/dev/null) || return 2
[[ -n "$bot_comments" ]] || return 1
local lower
lower=$(printf '%s' "$bot_comments" | tr '[:upper:]' '[:lower:]')
case "$lower" in
*"waiting only on a maintainer click"*) return 0 ;;
*"waiting only on a maintainer merge click"*) return 0 ;;
*"ready to merge when convenient"*) return 0 ;;
*"blocked by missing mergepullrequest permission"*) return 0 ;;
esac
# "ready (to|for) merge @<maintainer>" — the @-mention indicates the ball
# is explicitly in the maintainer's court. Bare "ready to merge" is too
# broad, so we require the @-mention as the maintainer-handoff signal.
if printf '%s' "$lower" | grep -qE 'ready (to|for) merge @[a-z0-9_-]+'; then
return 0
fi
return 1
}
# Suppress notifications for PRs that are already merge-ready and where the
# bot has already left a maintainer-facing "waiting only on a maintainer click"
# status comment. These stay OPEN from the notification system's perspective,
# but revisiting them produces fake-ready work and repeated comment churn.
is_permission_blocked_merge_ready_pr() {
local repo=$1
local number=$2
# Bypass cache if disabled
if [ "${BOB_NOTIFICATION_CACHE_DISABLE:-0}" = "1" ]; then
_is_permission_blocked_merge_ready_pr_uncached "$repo" "$number"
else
_is_permission_blocked_merge_ready_pr_cached "$repo" "$number"
fi
}
# Helper function to format compactly with smart timestamps (limit 10 per category)
format_compact() {
local category=$1
local limit=10
local today
today=$(date +%Y-%m-%d)
echo "$category" | jq -r ".[] | \"\(.id)|\(.repository.full_name)|\(.subject.title)|\(.subject.type)|\(.subject.url)|\(.updated_at)\"" | head -$limit | while IFS='|' read -r id repo title type url timestamp; do
# If --only-open flag set, filter out closed/merged items
if [ "$ONLY_OPEN" = true ] && [[ "$type" == "PullRequest" || "$type" == "Issue" ]]; then
local number
number=$(echo "$url" | grep -oP '\d+$')
if ! is_item_open "$repo" "$number" "$type"; then
continue # Skip closed/merged items
fi
if [[ "$type" == "PullRequest" ]] && is_permission_blocked_merge_ready_pr "$repo" "$number"; then
continue # Skip fake-ready PR churn already blocked on maintainer merge permission
fi
fi
local date
local time
date=$(echo "$timestamp" | cut -d'T' -f1)
time=$(echo "$timestamp" | cut -d'T' -f2 | cut -d':' -f1,2)
local time_str
if [ "$date" = "$today" ]; then
time_str="$time"
else
time_str="$date"
fi
# Add type indicator
local type_indicator=""
case "$type" in
PullRequest) type_indicator="[PR]" ;;
Issue) type_indicator="[Issue]" ;;
*) type_indicator="[$type]" ;;
esac
if [ "$WITH_IDS" = true ]; then
echo "- $type_indicator $repo: $title ($time_str) [ID: $id]"
else
echo "- $type_indicator $repo: $title ($time_str)"
fi
done
}
# Show high-priority categories first
if [ "$MENTION_COUNT" -gt 0 ]; then
echo "**Mentions** ($MENTION_COUNT):"
format_compact "$MENTIONS"
[ "$MENTION_COUNT" -gt 10 ] && echo " ... and $((MENTION_COUNT - 10)) more"
echo
fi
if [ "$ASSIGN_COUNT" -gt 0 ]; then
echo "**Assigned** ($ASSIGN_COUNT):"
format_compact "$ASSIGNS"
[ "$ASSIGN_COUNT" -gt 10 ] && echo " ... and $((ASSIGN_COUNT - 10)) more"
echo
fi
if [ "$REVIEW_COUNT" -gt 0 ]; then
echo "**Review Requests** ($REVIEW_COUNT):"
format_compact "$REVIEWS"
[ "$REVIEW_COUNT" -gt 10 ] && echo " ... and $((REVIEW_COUNT - 10)) more"
echo
fi
if [ "$AUTHOR_COUNT" -gt 0 ]; then
echo "**Your Items** ($AUTHOR_COUNT):"
format_compact "$AUTHOR"
[ "$AUTHOR_COUNT" -gt 10 ] && echo " ... and $((AUTHOR_COUNT - 10)) more"
echo
fi
if [ "$COMMENT_COUNT" -gt 0 ]; then
echo "**Comments** ($COMMENT_COUNT):"
format_compact "$COMMENTS"
[ "$COMMENT_COUNT" -gt 10 ] && echo " ... and $((COMMENT_COUNT - 10)) more"
echo
fi
if [ "$OTHER_COUNT" -gt 0 ]; then
echo "**Other** ($OTHER_COUNT):"
format_compact "$OTHERS"
[ "$OTHER_COUNT" -gt 10 ] && echo " ... and $((OTHER_COUNT - 10)) more"
echo
fi
echo "💡 Use ./scripts/github/check-notifications.sh --help for management options"