Setting the file. One moment.
Gather gh Search · Daily Recap · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page 46.10
README
scripts/gather-gh-search.sh
scripts/ gather-gh-search.sh
Shell · 392 lines · 14 KB
# With one date: a single workday starting on START-DATE, same as above.
15 # With two dates: a multi-day window running from the cutoff hour on START-DATE to
16 # the cutoff hour on the day after END-DATE, local time, so END-DATE is inclusive.
17 # A 7-day run looks like `gather-gh-search.sh 2026-10-15 2026-10-21`.
18 #
19 # Environment:
20 # DAILY_RECAP_CUTOFF_HOUR Local hour (0-23) at which a workday begins. Default: 7.
21 # TZ Standard timezone override, e.g. America/Los_Angeles.
22 #
23 # Output (stdout): a JSON object with "login", "window", "search_complete", "pr_count",
24 # and "prs". Each PR carries repo, number, title, state, is_draft, url, author, the
25 # user's role(s) (authored, reviewed, and/or commented), its in-window activity sorted
26 # by time, and last_activity. The window line and any pagination-cap warning also go to
27 # stderr. PR titles are passed through raw; HTML-escaping happens at render time.
28 #
29 # CRITICAL: GitHub's search `updated:>=` filter matches a PR's last-updated timestamp,
30 # not per-event activity, so every candidate PR's activity is filtered client-side
31 # against the window, and only activity performed by the user themselves is counted. A PR
32 # with no qualifying in-window activity by the user is dropped even if search found it.
33 #
34 # Requires: gh (authenticated), jq, and GNU or BSD date.
35
36 set -euo pipefail
37
38 SCRIPT_DIR = "$( cd "$( dirname "${ BASH_SOURCE [0]}")" && pwd )"
39 # shellcheck source=SCRIPTDIR/date-utils.sh
40 source "${ SCRIPT_DIR }/date-utils.sh"
41
42 die () {
43 echo "gather-gh-search: $* " >&2
44 exit 1
45 }
46
47 command -v jq > /dev/null 2>&1 || die "'jq' is required but not installed"
48
49 # Safety cap against unbounded pagination on a very busy window, per query.
50 readonly MAX_SEARCH_PAGES = 20
51
52 cutoff_raw = "${ DAILY_RECAP_CUTOFF_HOUR :- 7 }"
53 if ! [[ " $cutoff_raw " =~ ^[0-9]{1,2}$ ]] || (( 10 #$cutoff_raw > 23 )); then
54 die "DAILY_RECAP_CUTOFF_HOUR must be a whole hour from 0 to 23 (got '${ cutoff_raw }')"
55 fi
56 CUTOFF_HOUR = $(( 10#$cutoff_raw ))
57 CUTOFF_HH = "$( printf '%02d' " $CUTOFF_HOUR ")"
58
59 TODAY = "$( date +%Y-%m-%d)"
60 if [[ $# -ge 2 ]]; then
61 DATE = " $1 "
62 END_DATE = " $2 "
63 is_valid_date " $DATE " || die "start date must be a real calendar date in YYYY-MM-DD form (got '${ DATE }')"
64 is_valid_date " $END_DATE " || die "end date must be a real calendar date in YYYY-MM-DD form (got '${ END_DATE }')"
65 [[ " $END_DATE " < " $DATE " ]] && die "end date (${ END_DATE }) must not be before start date (${ DATE })"
66 elif [[ $# -eq 1 ]]; then
67 DATE = " $1 "
68 is_valid_date " $DATE " || die "date must be a real calendar date in YYYY-MM-DD form (got '${ DATE }')"
69 END_DATE = " $DATE "
70 elif (( 10 #$(date +% H) >= CUTOFF_HOUR)); then
71 DATE = "$( add_days " $TODAY " -1 )"
72 END_DATE = " $DATE "
73 else
74 DATE = "$( add_days " $TODAY " -2 )"
75 END_DATE = " $DATE "
76 fi
77
78 NEXT_DATE = "$( add_days " $END_DATE " 1 )"
79 START_EPOCH = "$( local_to_epoch "${ DATE } ${ CUTOFF_HH }:00:00")" \
80 || die "could not compute the window start for ${ DATE } ${ CUTOFF_HH }:00 local time"
81 END_EPOCH = "$( local_to_epoch "${ NEXT_DATE } ${ CUTOFF_HH }:00:00")" \
82 || die "could not compute the window end for ${ NEXT_DATE } ${ CUTOFF_HH }:00 local time"
83
84 START_UTC = "$( epoch_fmt_utc " $START_EPOCH " '%Y-%m-%dT%H:%M:%SZ')"
85 END_UTC = "$( epoch_fmt_utc " $END_EPOCH " '%Y-%m-%dT%H:%M:%SZ')"
86 START_LOCAL = "$( epoch_fmt " $START_EPOCH " '%Y-%m-%dT%H:%M:%S%z')"
87 END_LOCAL = "$( epoch_fmt " $END_EPOCH " '%Y-%m-%dT%H:%M:%S%z')"
88 TZ_LABEL = "$( epoch_fmt " $START_EPOCH " '%Z')"
89
90 echo "# Window: ${ START_UTC } -> ${ END_UTC } (${ CUTOFF_HH }:00 ${ TZ_LABEL } boundary)" >&2
91
92 # Checked only after the window is printed, so a recap built without GitHub still has it.
93 command -v gh > /dev/null 2>&1 || die "'gh' is required but not installed"
94 LOGIN = "$( gh api user --jq .login 2> /dev/null)" \
95 || die "could not read your GitHub login; run 'gh auth login' and try again"
96
97 WORK_DIR = "$( mktemp -d "${ TMPDIR :-/ tmp }/daily-recap-gh-search.XXXXXX")"
98 trap 'rm -rf "$WORK_DIR"' EXIT
99
100 # Search's `updated:` qualifier matches a PR's *last* update time, not when the user's
101 # activity happened, so it can only be used as a floor: a PR worked on inside the window
102 # still gets dropped by an upper bound if anyone touches it again afterward (a merge, a CI
103 # push, a reply). Floor-only only needs to be as precise as a calendar date; the exact
104 # epoch filtering happens client-side in jq below. Over-scanning old date ranges is already
105 # handled by MAX_SEARCH_PAGES and its search_complete: false warning.
106 FLOOR = "${ START_UTC : 0 : 10 }"
107
108 AUTHORED_QUERY = '
109 query($q: String!, $cursor: String) {
110 search(query: $q, type: ISSUE, first: 50, after: $cursor) {
111 pageInfo { hasNextPage endCursor }
112 nodes {
113 __typename
114 ... on PullRequest {
115 number
116 title
117 url
118 state
119 isDraft
120 createdAt
121 mergedAt
122 author { login }
123 repository { nameWithOwner }
124 timelineItems(last: 100, itemTypes: [PULL_REQUEST_COMMIT, ISSUE_COMMENT, PULL_REQUEST_REVIEW, READY_FOR_REVIEW_EVENT]) {
125 nodes {
126 __typename
127 ... on PullRequestCommit {
128 commit { committedDate author { user { login } } }
129 }
130 ... on IssueComment {
131 createdAt
132 author { login }
133 }
134 ... on PullRequestReview {
135 submittedAt
136 author { login }
137 }
138 ... on ReadyForReviewEvent {
139 createdAt
140 actor { login }
141 }
142 }
143 }
144 }
145 }
146 }
147 }
148 '
149
150 REVIEWED_QUERY = '
151 query($q: String!, $cursor: String) {
152 search(query: $q, type: ISSUE, first: 50, after: $cursor) {
153 pageInfo { hasNextPage endCursor }
154 nodes {
155 __typename
156 ... on PullRequest {
157 number
158 title
159 url
160 state
161 isDraft
162 createdAt
163 mergedAt
164 author { login }
165 repository { nameWithOwner }
166 reviews(last: 100) {
167 nodes {
168 state
169 submittedAt
170 author { login }
171 }
172 }
173 }
174 }
175 }
176 }
177 '
178
179 COMMENTED_QUERY = '
180 query($q: String!, $cursor: String) {
181 search(query: $q, type: ISSUE, first: 50, after: $cursor) {
182 pageInfo { hasNextPage endCursor }
183 nodes {
184 __typename
185 ... on PullRequest {
186 number
187 title
188 url
189 state
190 isDraft
191 createdAt
192 mergedAt
193 author { login }
194 repository { nameWithOwner }
195 comments(last: 100) {
196 nodes {
197 createdAt
198 author { login }
199 }
200 }
201 }
202 }
203 }
204 }
205 '
206
207 AUTHORED_Q = "author:${ LOGIN } type:pr updated:>=${ FLOOR }"
208 REVIEWED_Q = "reviewed-by:${ LOGIN } type:pr -author:${ LOGIN } updated:>=${ FLOOR }"
209 COMMENTED_Q = "commenter:${ LOGIN } type:pr -author:${ LOGIN } updated:>=${ FLOOR }"
210
211 SEARCH_COMPLETE = true
212
213 # run_search LABEL QUERY_DOC SEARCH_STRING -> paginates the search, writing each page's
214 # nodes to WORK_DIR/<label>-pages/page-N.json and the combined node array to
215 # WORK_DIR/<label>-nodes.json. Dies naming LABEL on a transport failure, or on a non-null
216 # `errors` field when `.data.search` came back null (no usable data). If `.data.search`
217 # has usable data despite a non-null `errors` field (partial failure), warns on stderr
218 # and sets SEARCH_COMPLETE=false instead of dying. Also sets SEARCH_COMPLETE=false and
219 # warns on stderr if the safety cap is hit with more pages remaining.
220 run_search () {
221 local label = " $1 " query = " $2 " search_string = " $3 "
222 local pages_dir = "${ WORK_DIR }/${ label }-pages"
223 mkdir -p " $pages_dir "
224 local cursor = "" page = 1 has_next = "false"
225
226 while ((page <= MAX_SEARCH_PAGES)); do
227 local resp = "${ WORK_DIR }/${ label }-resp-${ page }.json"
228 local err = "${ WORK_DIR }/${ label }-err-${ page }.txt"
229 if [[ -z " $cursor " ]]; then
230 gh api graphql -f query=" $query " -f q=" $search_string " > " $resp " 2> " $err " \
231 || die "gh api graphql failed for the ${ label } query: $( cat " $err ")"
232 else
233 gh api graphql -f query=" $query " -f q=" $search_string " -f cursor=" $cursor " > " $resp " 2> " $err " \
234 || die "gh api graphql failed for the ${ label } query: $( cat " $err ")"
235 fi
236
237 local errs
238 errs = "$( jq -r 'if (.errors // null) != null then (.errors | tostring) else empty end' " $resp " 2> /dev/null || true )"
239 if [[ -n " $errs " ]]; then
240 if [[ "$( jq -r '.data.search // empty' " $resp ")" == "" ]]; then
241 die "gh api graphql returned errors for the ${ label } query: ${ errs }"
242 fi
243 echo "# Warning: partial errors on the ${ label } query: ${ errs }" >&2
244 SEARCH_COMPLETE = false
245 fi
246
247 jq -c '.data.search.nodes // []' " $resp " > "${ pages_dir }/page-${ page }.json"
248
249 has_next = "$( jq -r '.data.search.pageInfo.hasNextPage // false' " $resp ")"
250 cursor = "$( jq -r '.data.search.pageInfo.endCursor // empty' " $resp ")"
251 if [[ " $has_next " != "true" || -z " $cursor " ]]; then
252 has_next = "false"
253 break
254 fi
255 page = $(( page + 1 ))
256 done
257
258 if [[ " $has_next " == "true" ]]; then
259 echo "# Warning: the ${ label } search hit the ${ MAX_SEARCH_PAGES }-page safety cap with more results remaining; some PRs may be missing." >&2
260 SEARCH_COMPLETE = false
261 fi
262
263 jq -s 'add // []' "${ pages_dir }"/page- * .json > "${ WORK_DIR }/${ label }-nodes.json"
264 }
265
266 run_search "authored" " $AUTHORED_QUERY " " $AUTHORED_Q "
267 run_search "reviewed" " $REVIEWED_QUERY " " $REVIEWED_Q "
268 run_search "commented" " $COMMENTED_QUERY " " $COMMENTED_Q "
269
270 # shape_search LABEL ROLE ACTIVITY_FILTER -> emits one JSON object (as a single-line
271 # array) per candidate PR that has at least one in-window activity entry attributable to
272 # the user themselves. ACTIVITY_FILTER is a jq expression, evaluated with $pr bound to the
273 # PullRequest node, that must produce a (possibly empty) stream of activity objects.
274 shape_search () {
275 local label = " $1 " role = " $2 " activity_filter = " $3 "
276 jq -c \
277 --arg login " $LOGIN " --arg start " $START_UTC " --arg end " $END_UTC " --arg role " $role " '
278 def in_window(t): (t != null) and (t >= $start) and (t < $end);
279 [ .[]
280 | select(.__typename == "PullRequest")
281 | . as $pr
282 | ('" $activity_filter "') as $activity
283 | select(($activity | length) > 0)
284 | {
285 url: $pr.url,
286 repo: $pr.repository.nameWithOwner,
287 number: $pr.number,
288 title: $pr.title,
289 state: $pr.state,
290 is_draft: $pr.isDraft,
291 author: ($pr.author.login // null),
292 role: $role,
293 activity: $activity
294 }
295 ] | .[]
296 ' "${ WORK_DIR }/${ label }-nodes.json" > "${ WORK_DIR }/${ label }-items.ndjson"
297 }
298
299 AUTHORED_ACTIVITY = '
300 ( [ (if in_window($pr.createdAt) then {time: $pr.createdAt, type: "opened"} else empty end) ]
301 + [ ($pr.timelineItems.nodes // [])[]?
302 | if .__typename == "PullRequestCommit" then
303 (if ((.commit.author.user.login // "") == $login) and in_window(.commit.committedDate)
304 then {time: .commit.committedDate, type: "commit"} else empty end)
305 elif .__typename == "IssueComment" then
306 (if ((.author.login // "") == $login) and in_window(.createdAt)
307 then {time: .createdAt, type: "comment"} else empty end)
308 elif .__typename == "PullRequestReview" then
309 (if ((.author.login // "") == $login) and in_window(.submittedAt)
310 then {time: .submittedAt, type: "review"} else empty end)
311 elif .__typename == "ReadyForReviewEvent" then
312 (if ((.actor.login // "") == $login) and in_window(.createdAt)
313 then {time: .createdAt, type: "ready_for_review"} else empty end)
314 else empty end
315 ]
316 )
317 '
318
319 REVIEWED_ACTIVITY = '
320 [ ($pr.reviews.nodes // [])[]?
321 | select(((.author.login // "") == $login) and in_window(.submittedAt))
322 | {time: .submittedAt, type: "review", review_state: .state}
323 ]
324 '
325
326 COMMENTED_ACTIVITY = '
327 [ ($pr.comments.nodes // [])[]?
328 | select(((.author.login // "") == $login) and in_window(.createdAt))
329 | {time: .createdAt, type: "comment"}
330 ]
331 '
332
333 shape_search "authored" "authored" " $AUTHORED_ACTIVITY "
334 shape_search "reviewed" "reviewed" " $REVIEWED_ACTIVITY "
335 shape_search "commented" "commented" " $COMMENTED_ACTIVITY "
336
337 : > "${ WORK_DIR }/all-items.ndjson"
338 for label in authored reviewed commented ; do
339 cat "${ WORK_DIR }/${ label }-items.ndjson" >> "${ WORK_DIR }/all-items.ndjson"
340 done
341
342 PRS_JSON = "$( jq -s '
343 group_by(.url)
344 | map(
345 .[0] as $base
346 | . as $group
347 | {
348 repo: $base.repo,
349 number: $base.number,
350 title: $base.title,
351 state: $base.state,
352 is_draft: $base.is_draft,
353 url: $base.url,
354 author: $base.author,
355 roles: (["authored", "reviewed", "commented"]
356 | map(select(. as $r | $group | any(.role == $r)))),
357 activity: ([$group[].activity[]] | unique_by([.type, .time]) | sort_by(.time))
358 }
359 | . + {last_activity: (.activity | last | .time)}
360 )
361 | sort_by(.last_activity)
362 ' "${ WORK_DIR }/all-items.ndjson")"
363
364 jq -n \
365 --arg login " $LOGIN " \
366 --arg start_date " $DATE " \
367 --arg end_date " $END_DATE " \
368 --argjson cutoff " $CUTOFF_HOUR " \
369 --arg tz " $TZ_LABEL " \
370 --arg start " $START_UTC " \
371 --arg end " $END_UTC " \
372 --arg start_local " $START_LOCAL " \
373 --arg end_local " $END_LOCAL " \
374 --argjson complete " $SEARCH_COMPLETE " \
375 --argjson prs " $PRS_JSON " '
376 {
377 login: $login,
378 window: {
379 start_date: $start_date,
380 end_date: $end_date,
381 cutoff_hour: $cutoff,
382 timezone: $tz,
383 start_utc: $start,
384 end_utc: $end,
385 start_local: $start_local,
386 end_local: $end_local
387 },
388 search_complete: $complete,
389 pr_count: ($prs | length),
390 prs: $prs
391 }
392 '