master
sh 231 lines 10.7 KB
Raw
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"