| 1 | #!/usr/bin/env bash |
| 2 | # Fetch ALL comments / reviews / review threads for a PR, with paranoid pagination. |
| 3 | # |
| 4 | # Usage: |
| 5 | # fetch-all.sh <pr-number> |
| 6 | # |
| 7 | # Outputs (under .local/audits/pr-reviews/pr-<N>/): |
| 8 | # pr.json -- gh pr view dump (state, head sha, etc.) |
| 9 | # issue-comments.json -- /repos/{slug}/issues/{n}/comments (top-level PR comments) |
| 10 | # review-comments.json -- /repos/{slug}/pulls/{n}/comments (line-level inline comments) |
| 11 | # reviews.json -- /repos/{slug}/pulls/{n}/reviews (review submissions with body) |
| 12 | # review-threads.json -- GraphQL reviewThreads (per-thread isResolved + comments[]) |
| 13 | # summary.txt -- human-readable triage summary |
| 14 | # |
| 15 | # Pagination paranoia: |
| 16 | # GitHub paginates everything. Default page size is 30, max 100. The skill's |
| 17 | # #1 rule is: "do not stop at the pagination boundary -- if you see exactly |
| 18 | # 100/200/300 items the round number is suspect; fetch one more page even |
| 19 | # when the Link header says no more, just to confirm." |
| 20 | # |
| 21 | # This script: |
| 22 | # 1. Uses --paginate (gh follows Link rel="next" automatically). |
| 23 | # 2. Re-checks each result count against round-number multiples of 100. |
| 24 | # If suspicious, explicitly requests page=N+1 and merges. |
| 25 | # 3. Logs the final count for each source so the caller can verify. |
| 26 | |
| 27 | set -euo pipefail |
| 28 | |
| 29 | # shellcheck source=./_lib.sh |
| 30 | # shellcheck disable=SC1091 |
| 31 | source "$(dirname "$0")/_lib.sh" |
| 32 | pr_require_gh |
| 33 | |
| 34 | PR="${1:?usage: $0 <pr-number>}" |
| 35 | pr_require_numeric "${PR}" |
| 36 | SLUG="$(pr_require_slug)" |
| 37 | DIR="$(pr_state_dir "${PR}")" |
| 38 | |
| 39 | echo -e "${PR_GRAY}[fetch-all] PR ${SLUG}#${PR} -> ${DIR}${PR_NC}" >&2 |
| 40 | |
| 41 | # --- pr.json (state, head sha, draft, requested reviewers, ...) ------------ |
| 42 | gh pr view "${PR}" --repo "${SLUG}" --json \ |
| 43 | number,title,state,isDraft,headRefName,headRefOid,baseRefName,baseRefOid,reviewDecision,reviewRequests,mergeable,mergeStateStatus,statusCheckRollup,labels,createdAt,updatedAt,author \ |
| 44 | > "${DIR}/pr.json" |
| 45 | |
| 46 | # --- Helper: fetch a paginated REST endpoint with paranoia ----------------- |
| 47 | # Args: <api-path> <output-file> <kind-label> |
| 48 | fetch_paranoid() { |
| 49 | local path="$1" out="$2" kind="$3" |
| 50 | # Ask for max page size and let gh follow rel=next. |
| 51 | local sep |
| 52 | if [[ "${path}" == *\?* ]]; then sep='&'; else sep='?'; fi |
| 53 | # `gh api --paginate` writes the per-page JSON arrays back-to-back |
| 54 | # (e.g. `[a,b,c][d,e]`), which is NOT a single valid JSON array. Pipe |
| 55 | # through `jq -s 'add'` to slurp the multiple top-level values and |
| 56 | # concatenate them into one array. (`gh api --paginate --jq '.[]'` |
| 57 | # would emit JSONL but loses the array shape we need for the rest of |
| 58 | # the loop.) |
| 59 | gh api --paginate "${path}${sep}per_page=100" | jq -s 'add // []' > "${out}" |
| 60 | |
| 61 | # Did we end up with a JSON array? |
| 62 | if ! jq -e 'type=="array"' "${out}" >/dev/null 2>&1; then |
| 63 | echo -e "${PR_RED}[fetch-all] ${kind}: response is not a JSON array. Auth? Rate limit?${PR_NC}" >&2 |
| 64 | head -c 200 "${out}" >&2; echo >&2 |
| 65 | return 1 |
| 66 | fi |
| 67 | |
| 68 | local n |
| 69 | n="$(jq 'length' "${out}")" |
| 70 | echo -e "${PR_GRAY}[fetch-all] ${kind}: ${n} items${PR_NC}" >&2 |
| 71 | |
| 72 | # Paranoia check: round multiples of 100 are suspicious. Explicitly |
| 73 | # request page=N+1 to confirm we've reached the end. |
| 74 | if (( n > 0 && n % 100 == 0 )); then |
| 75 | local next_page=$(( n / 100 + 1 )) |
| 76 | echo -e "${PR_YELLOW}[fetch-all] ${kind}: count is exactly ${n} (multiple of 100). Verifying with explicit page=${next_page}...${PR_NC}" >&2 |
| 77 | |
| 78 | local probe |
| 79 | probe="$(gh api "${path}${sep}per_page=100&page=${next_page}" 2>/dev/null || echo '[]')" |
| 80 | local extra |
| 81 | extra="$(jq 'length' <<< "${probe}")" |
| 82 | if (( extra > 0 )); then |
| 83 | echo -e "${PR_YELLOW}[fetch-all] ${kind}: page ${next_page} had ${extra} more items! Merging.${PR_NC}" >&2 |
| 84 | # Merge and continue probing further pages until empty. |
| 85 | jq -s '.[0] + .[1]' "${out}" <(printf '%s' "${probe}") > "${out}.merged" |
| 86 | mv "${out}.merged" "${out}" |
| 87 | local p=$(( next_page + 1 )) |
| 88 | while true; do |
| 89 | probe="$(gh api "${path}${sep}per_page=100&page=${p}" 2>/dev/null || echo '[]')" |
| 90 | extra="$(jq 'length' <<< "${probe}")" |
| 91 | (( extra == 0 )) && break |
| 92 | echo -e "${PR_YELLOW}[fetch-all] ${kind}: page ${p} had ${extra} more.${PR_NC}" >&2 |
| 93 | jq -s '.[0] + .[1]' "${out}" <(printf '%s' "${probe}") > "${out}.merged" |
| 94 | mv "${out}.merged" "${out}" |
| 95 | p=$(( p + 1 )) |
| 96 | done |
| 97 | n="$(jq 'length' "${out}")" |
| 98 | echo -e "${PR_GREEN}[fetch-all] ${kind}: final count after probing: ${n}${PR_NC}" >&2 |
| 99 | else |
| 100 | echo -e "${PR_GRAY}[fetch-all] ${kind}: page ${next_page} empty -- ${n} confirmed.${PR_NC}" >&2 |
| 101 | fi |
| 102 | fi |
| 103 | } |
| 104 | |
| 105 | # --- Three REST sources ---------------------------------------------------- |
| 106 | fetch_paranoid "/repos/${SLUG}/issues/${PR}/comments" "${DIR}/issue-comments.json" "issue-comments" |
| 107 | fetch_paranoid "/repos/${SLUG}/pulls/${PR}/comments" "${DIR}/review-comments.json" "review-comments" |
| 108 | fetch_paranoid "/repos/${SLUG}/pulls/${PR}/reviews" "${DIR}/reviews.json" "reviews" |
| 109 | |
| 110 | # --- GraphQL reviewThreads (resolved state + thread IDs) ------------------- |
| 111 | # REST does not expose review-thread IDs or isResolved -- we need GraphQL for |
| 112 | # resolve-thread.sh. Pagination uses cursors here, not pages. |
| 113 | echo -e "${PR_GRAY}[fetch-all] review-threads (GraphQL)${PR_NC}" >&2 |
| 114 | owner="${SLUG%%/*}" |
| 115 | name="${SLUG##*/}" |
| 116 | |
| 117 | threads_tmp="$(mktemp "${TMPDIR:-/tmp}/pr-threads-XXXXXX.json")" |
| 118 | trap 'rm -f "${threads_tmp}"' EXIT |
| 119 | |
| 120 | cursor="" |
| 121 | echo '[]' > "${DIR}/review-threads.json" |
| 122 | while true; do |
| 123 | cursor_args=() |
| 124 | if [[ -n "${cursor}" ]]; then |
| 125 | cursor_args+=(-F "after=${cursor}") |
| 126 | fi |
| 127 | # The single-quoted GraphQL string contains $owner/$name/$number as |
| 128 | # GraphQL placeholders, not shell variables; SC2016 is expected here. |
| 129 | # shellcheck disable=SC2016 |
| 130 | gh api graphql -F owner="${owner}" -F name="${name}" -F number="${PR}" "${cursor_args[@]}" -f query=' |
| 131 | query($owner:String!, $name:String!, $number:Int!, $after:String) { |
| 132 | repository(owner:$owner, name:$name) { |
| 133 | pullRequest(number:$number) { |
| 134 | reviewThreads(first:100, after:$after) { |
| 135 | pageInfo { hasNextPage endCursor } |
| 136 | nodes { |
| 137 | id |
| 138 | isResolved |
| 139 | isOutdated |
| 140 | path |
| 141 | line |
| 142 | comments(first:100) { |
| 143 | pageInfo { hasNextPage endCursor } |
| 144 | totalCount |
| 145 | nodes { |
| 146 | id |
| 147 | databaseId |
| 148 | body |
| 149 | author { login } |
| 150 | createdAt |
| 151 | url |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | ' > "${threads_tmp}" |
| 160 | |
| 161 | page_nodes="$(jq '.data.repository.pullRequest.reviewThreads.nodes' "${threads_tmp}")" |
| 162 | n_page="$(jq 'length' <<< "${page_nodes}")" |
| 163 | # Append to running file |
| 164 | jq -s '.[0] + .[1]' "${DIR}/review-threads.json" <(printf '%s' "${page_nodes}") > "${DIR}/review-threads.json.merged" |
| 165 | mv "${DIR}/review-threads.json.merged" "${DIR}/review-threads.json" |
| 166 | |
| 167 | # Warn if any thread on this page has more than 100 comments -- the |
| 168 | # inner connection is fetched first:100 only, so the tail is cut. |
| 169 | truncated_threads="$(jq -r ' |
| 170 | [.data.repository.pullRequest.reviewThreads.nodes[] |
| 171 | | select(.comments.pageInfo.hasNextPage) |
| 172 | | .id] | join(", ") |
| 173 | ' "${threads_tmp}")" |
| 174 | if [[ -n "${truncated_threads}" ]]; then |
| 175 | echo -e "${PR_YELLOW}[fetch-all] review-threads: nested comments truncated (>100) for: ${truncated_threads}${PR_NC}" >&2 |
| 176 | fi |
| 177 | |
| 178 | has_next="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' "${threads_tmp}")" |
| 179 | cursor="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor' "${threads_tmp}")" |
| 180 | echo -e "${PR_GRAY}[fetch-all] review-threads: +${n_page} (hasNext=${has_next})${PR_NC}" >&2 |
| 181 | [[ "${has_next}" == "true" ]] || break |
| 182 | done |
| 183 | n_threads="$(jq 'length' "${DIR}/review-threads.json")" |
| 184 | echo -e "${PR_GRAY}[fetch-all] review-threads: ${n_threads} threads total${PR_NC}" >&2 |
| 185 | |
| 186 | # --- summary.txt ----------------------------------------------------------- |
| 187 | { |
| 188 | echo "PR ${SLUG}#${PR} -- snapshot $(pr_now_utc)" |
| 189 | state=$(jq -r '.state' "${DIR}/pr.json") |
| 190 | is_draft=$(jq -r '.isDraft' "${DIR}/pr.json") |
| 191 | head_oid=$(jq -r '.headRefOid' "${DIR}/pr.json") |
| 192 | head_ref=$(jq -r '.headRefName' "${DIR}/pr.json") |
| 193 | base_oid=$(jq -r '.baseRefOid' "${DIR}/pr.json") |
| 194 | base_ref=$(jq -r '.baseRefName' "${DIR}/pr.json") |
| 195 | decision=$(jq -r '.reviewDecision // "none"' "${DIR}/pr.json") |
| 196 | mergeable=$(jq -r '.mergeable' "${DIR}/pr.json") |
| 197 | merge_state=$(jq -r '.mergeStateStatus' "${DIR}/pr.json") |
| 198 | [[ "${is_draft}" == "true" ]] && state="${state} (draft)" |
| 199 | printf 'State: %s\nHead: %s on %s\nBase: %s on %s\nDecision: %s\nMerge: %s / %s\n' \ |
| 200 | "${state}" "${head_oid:0:10}" "${head_ref}" "${base_oid:0:10}" "${base_ref}" \ |
| 201 | "${decision}" "${mergeable}" "${merge_state}" |
| 202 | echo |
| 203 | echo "Reviewers requested:" |
| 204 | jq -r '(.reviewRequests // [])[] | " - " + (.login // .name // "?")' "${DIR}/pr.json" |
| 205 | echo |
| 206 | echo "Counts:" |
| 207 | printf ' issue-comments : %d\n' "$(jq 'length' "${DIR}/issue-comments.json")" |
| 208 | printf ' review-comments : %d\n' "$(jq 'length' "${DIR}/review-comments.json")" |
| 209 | printf ' reviews : %d\n' "$(jq 'length' "${DIR}/reviews.json")" |
| 210 | n_resolved=$(jq '[.[] | select(.isResolved)] | length' "${DIR}/review-threads.json") |
| 211 | n_open=$(jq '[.[] | select(.isResolved | not)] | length' "${DIR}/review-threads.json") |
| 212 | printf ' review-threads : %d (resolved: %d, open: %d)\n' "${n_threads}" "${n_resolved}" "${n_open}" |
| 213 | echo |
| 214 | echo "Authors involved (count of items per author across all sources):" |
| 215 | { |
| 216 | jq -r '.[] | .user.login' "${DIR}/issue-comments.json" |
| 217 | jq -r '.[] | .user.login' "${DIR}/review-comments.json" |
| 218 | jq -r '.[] | .user.login' "${DIR}/reviews.json" |
| 219 | } | sort | uniq -c | sort -rn | sed 's/^/ /' |
| 220 | echo |
| 221 | echo "Open review threads (need attention):" |
| 222 | jq -r '.[] | select(.isResolved | not) |
| 223 | | " THREAD " + .id |
| 224 | + " " + .path + ":" + ((.line // "?") | tostring) |
| 225 | + " comments=" + ((.comments.nodes | length) | tostring) |
| 226 | + " by=" + (.comments.nodes[0].author.login // "?")' \ |
| 227 | "${DIR}/review-threads.json" |
| 228 | } > "${DIR}/summary.txt" |
| 229 | |
| 230 | echo |
| 231 | cat "${DIR}/summary.txt" |