#!/usr/bin/env bash # Pull the authenticated user's authored, reviewed, and commented pull requests for a # window via GitHub's search API, and emit a compact JSON summary. # # gather-gh-events.sh reads the events feed, which holds at most 300 events from the # last 30 days. This script instead issues paginated `gh api graphql` searches, so it # has no such cap and can cover a multi-day window (e.g. a full week) in one run. # # Usage: # gather-gh-search.sh [START-DATE [END-DATE]] # With no dates: the same single-workday window gather-gh-events.sh defaults to # (yesterday once today's cutoff has passed, otherwise the day before), running # from the cutoff hour on that date to the cutoff hour on the next date. # With one date: a single workday starting on START-DATE, same as above. # With two dates: a multi-day window running from the cutoff hour on START-DATE to # the cutoff hour on the day after END-DATE, local time, so END-DATE is inclusive. # A 7-day run looks like `gather-gh-search.sh 2026-10-15 2026-10-21`. # # Environment: # DAILY_RECAP_CUTOFF_HOUR Local hour (0-23) at which a workday begins. Default: 7. # TZ Standard timezone override, e.g. America/Los_Angeles. # # Output (stdout): a JSON object with "login", "window", "search_complete", "pr_count", # and "prs". Each PR carries repo, number, title, state, is_draft, url, author, the # user's role(s) (authored, reviewed, and/or commented), its in-window activity sorted # by time, and last_activity. The window line and any pagination-cap warning also go to # stderr. PR titles are passed through raw; HTML-escaping happens at render time. # # CRITICAL: GitHub's search `updated:>=` filter matches a PR's last-updated timestamp, # not per-event activity, so every candidate PR's activity is filtered client-side # against the window, and only activity performed by the user themselves is counted. A PR # with no qualifying in-window activity by the user is dropped even if search found it. # # Requires: gh (authenticated), jq, and GNU or BSD date. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=SCRIPTDIR/date-utils.sh source "${SCRIPT_DIR}/date-utils.sh" die() { echo "gather-gh-search: $*" >&2 exit 1 } command -v jq >/dev/null 2>&1 || die "'jq' is required but not installed" # Safety cap against unbounded pagination on a very busy window, per query. readonly MAX_SEARCH_PAGES=20 cutoff_raw="${DAILY_RECAP_CUTOFF_HOUR:-7}" if ! [[ "$cutoff_raw" =~ ^[0-9]{1,2}$ ]] || ((10#$cutoff_raw > 23)); then die "DAILY_RECAP_CUTOFF_HOUR must be a whole hour from 0 to 23 (got '${cutoff_raw}')" fi CUTOFF_HOUR=$((10#$cutoff_raw)) CUTOFF_HH="$(printf '%02d' "$CUTOFF_HOUR")" TODAY="$(date +%Y-%m-%d)" if [[ $# -ge 2 ]]; then DATE="$1" END_DATE="$2" is_valid_date "$DATE" || die "start date must be a real calendar date in YYYY-MM-DD form (got '${DATE}')" is_valid_date "$END_DATE" || die "end date must be a real calendar date in YYYY-MM-DD form (got '${END_DATE}')" [[ "$END_DATE" < "$DATE" ]] && die "end date (${END_DATE}) must not be before start date (${DATE})" elif [[ $# -eq 1 ]]; then DATE="$1" is_valid_date "$DATE" || die "date must be a real calendar date in YYYY-MM-DD form (got '${DATE}')" END_DATE="$DATE" elif ((10#$(date +%H) >= CUTOFF_HOUR)); then DATE="$(add_days "$TODAY" -1)" END_DATE="$DATE" else DATE="$(add_days "$TODAY" -2)" END_DATE="$DATE" fi NEXT_DATE="$(add_days "$END_DATE" 1)" START_EPOCH="$(local_to_epoch "${DATE} ${CUTOFF_HH}:00:00")" \ || die "could not compute the window start for ${DATE} ${CUTOFF_HH}:00 local time" END_EPOCH="$(local_to_epoch "${NEXT_DATE} ${CUTOFF_HH}:00:00")" \ || die "could not compute the window end for ${NEXT_DATE} ${CUTOFF_HH}:00 local time" START_UTC="$(epoch_fmt_utc "$START_EPOCH" '%Y-%m-%dT%H:%M:%SZ')" END_UTC="$(epoch_fmt_utc "$END_EPOCH" '%Y-%m-%dT%H:%M:%SZ')" START_LOCAL="$(epoch_fmt "$START_EPOCH" '%Y-%m-%dT%H:%M:%S%z')" END_LOCAL="$(epoch_fmt "$END_EPOCH" '%Y-%m-%dT%H:%M:%S%z')" TZ_LABEL="$(epoch_fmt "$START_EPOCH" '%Z')" echo "# Window: ${START_UTC} -> ${END_UTC} (${CUTOFF_HH}:00 ${TZ_LABEL} boundary)" >&2 # Checked only after the window is printed, so a recap built without GitHub still has it. command -v gh >/dev/null 2>&1 || die "'gh' is required but not installed" LOGIN="$(gh api user --jq .login 2>/dev/null)" \ || die "could not read your GitHub login; run 'gh auth login' and try again" WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/daily-recap-gh-search.XXXXXX")" trap 'rm -rf "$WORK_DIR"' EXIT # Search's `updated:` qualifier matches a PR's *last* update time, not when the user's # activity happened, so it can only be used as a floor: a PR worked on inside the window # still gets dropped by an upper bound if anyone touches it again afterward (a merge, a CI # push, a reply). Floor-only only needs to be as precise as a calendar date; the exact # epoch filtering happens client-side in jq below. Over-scanning old date ranges is already # handled by MAX_SEARCH_PAGES and its search_complete: false warning. FLOOR="${START_UTC:0:10}" AUTHORED_QUERY=' query($q: String!, $cursor: String) { search(query: $q, type: ISSUE, first: 50, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { __typename ... on PullRequest { number title url state isDraft createdAt mergedAt author { login } repository { nameWithOwner } timelineItems(last: 100, itemTypes: [PULL_REQUEST_COMMIT, ISSUE_COMMENT, PULL_REQUEST_REVIEW, READY_FOR_REVIEW_EVENT]) { nodes { __typename ... on PullRequestCommit { commit { committedDate author { user { login } } } } ... on IssueComment { createdAt author { login } } ... on PullRequestReview { submittedAt author { login } } ... on ReadyForReviewEvent { createdAt actor { login } } } } } } } } ' REVIEWED_QUERY=' query($q: String!, $cursor: String) { search(query: $q, type: ISSUE, first: 50, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { __typename ... on PullRequest { number title url state isDraft createdAt mergedAt author { login } repository { nameWithOwner } reviews(last: 100) { nodes { state submittedAt author { login } } } } } } } ' COMMENTED_QUERY=' query($q: String!, $cursor: String) { search(query: $q, type: ISSUE, first: 50, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { __typename ... on PullRequest { number title url state isDraft createdAt mergedAt author { login } repository { nameWithOwner } comments(last: 100) { nodes { createdAt author { login } } } } } } } ' AUTHORED_Q="author:${LOGIN} type:pr updated:>=${FLOOR}" REVIEWED_Q="reviewed-by:${LOGIN} type:pr -author:${LOGIN} updated:>=${FLOOR}" COMMENTED_Q="commenter:${LOGIN} type:pr -author:${LOGIN} updated:>=${FLOOR}" SEARCH_COMPLETE=true # run_search LABEL QUERY_DOC SEARCH_STRING -> paginates the search, writing each page's # nodes to WORK_DIR/