master
sh 132 lines 5.82 KB
Raw
1 #!/usr/bin/env bash
2 # Block until new activity appears on a PR (new comment, new review, new
3 # commit), or until the timeout fires.
4 #
5 # Usage:
6 # wait-for-activity.sh <pr-number> [<timeout-seconds>] [<poll-interval-seconds>]
7 #
8 # Defaults: timeout=1800 (30 min), poll=30s.
9 #
10 # Establishes a baseline by reading the cached fetch-all.sh dump (or fetching
11 # fresh if missing). Then polls every <poll-interval> seconds for changes.
12 # Exits 0 when something new is found; exits 124 on timeout.
13 #
14 # "New" means any of:
15 # - issue-comments count changed
16 # - review-comments count changed
17 # - reviews count changed
18 # - PR head sha changed (new push)
19 # - reviewThreads isResolved transitions
20 #
21 # Both cubic-dev-ai and copilot post a comment when they have nothing new
22 # to add -- so this loop ends naturally on a clean re-review.
23 #
24 # Note about Costa's rule: "the PR should never be left with unaddressed
25 # comments". After this returns, run fetch-all.sh again, classify the new
26 # activity, and address it.
27
28 set -euo pipefail
29
30 # shellcheck source=./_lib.sh
31 # shellcheck disable=SC1091
32 source "$(dirname "$0")/_lib.sh"
33 pr_require_gh
34
35 PR="${1:?usage: $0 <pr-number> [<timeout-seconds>] [<poll-interval-seconds>]}"
36 pr_require_numeric "${PR}"
37 TIMEOUT="${2:-1800}"
38 POLL="${3:-30}"
39
40 pr_require_numeric "${TIMEOUT}" "timeout-seconds"
41 pr_require_numeric "${POLL}" "poll-interval-seconds"
42
43 SLUG="$(pr_require_slug)"
44
45 snapshot() {
46 # Quick snapshot for change detection -- not a full fetch.
47 #
48 # `gh api --paginate ... --jq '.field'` emits one value per page (JSONL),
49 # so we sum them with awk. `gh api --paginate ... | jq 'length'` would
50 # NOT work here -- the concatenated arrays from paginate are not a
51 # single JSON value.
52 gh pr view "${PR}" --repo "${SLUG}" --json headRefOid \
53 --jq '"head=\(.headRefOid)"'
54 gh api --paginate "/repos/${SLUG}/issues/${PR}/comments?per_page=100" --jq 'if type=="array" then length else 0 end' 2>/dev/null \
55 | awk 'BEGIN{t=0} {t+=$1} END{print "n_issue="t}'
56 gh api --paginate "/repos/${SLUG}/pulls/${PR}/comments?per_page=100" --jq 'if type=="array" then length else 0 end' 2>/dev/null \
57 | awk 'BEGIN{t=0} {t+=$1} END{print "n_review_comment="t}'
58 gh api --paginate "/repos/${SLUG}/pulls/${PR}/reviews?per_page=100" --jq 'if type=="array" then length else 0 end' 2>/dev/null \
59 | awk 'BEGIN{t=0} {t+=$1} END{print "n_review="t}'
60 # Track resolve/unresolve transitions via the GraphQL reviewThreads
61 # connection -- counts of resolved/open threads. A thread getting
62 # resolved/unresolved is real PR activity that the REST counts above
63 # would miss.
64 #
65 # IMPORTANT: this line MUST always be present in the snapshot, even on
66 # transient GraphQL failure. If it disappears intermittently, the
67 # snapshot diff would falsely show "new activity" each time it returns.
68 # We collect the GraphQL output into a variable, fall back to a fixed
69 # literal on any failure, and emit one canonical line.
70 local owner name graphql_out
71 owner="${SLUG%%/*}"
72 name="${SLUG##*/}"
73 # Cursor-paginate threads so PRs with >100 threads are tracked correctly.
74 local cursor='' resolved=0 open=0
75 while :; do
76 local cursor_args=()
77 [[ -n "${cursor}" ]] && cursor_args+=(-F "after=${cursor}")
78 # GraphQL string has $owner/$name/$number/$after as placeholders.
79 # shellcheck disable=SC2016
80 graphql_out="$(gh api graphql -F owner="${owner}" -F name="${name}" -F number="${PR}" \
81 "${cursor_args[@]}" -f query='
82 query($owner:String!, $name:String!, $number:Int!, $after:String) {
83 repository(owner:$owner, name:$name) {
84 pullRequest(number:$number) {
85 reviewThreads(first:100, after:$after) {
86 pageInfo { hasNextPage endCursor }
87 nodes { isResolved }
88 }
89 }
90 }
91 }' 2>/dev/null)" || { echo "threads_resolved=ERR_open=ERR"; return 0; }
92 local r o
93 r=$(jq -r '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved)] | length' <<< "${graphql_out}" 2>/dev/null) || r=0
94 o=$(jq -r '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved | not)] | length' <<< "${graphql_out}" 2>/dev/null) || o=0
95 resolved=$(( resolved + r ))
96 open=$(( open + o ))
97 local hasnext nextcur
98 hasnext=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage // false' <<< "${graphql_out}" 2>/dev/null)
99 nextcur=$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // ""' <<< "${graphql_out}" 2>/dev/null)
100 [[ "${hasnext}" == "true" ]] || break
101 cursor="${nextcur}"
102 done
103 echo "threads_resolved=${resolved}_open=${open}"
104 }
105
106 echo -e "${PR_GRAY}[wait] PR ${SLUG}#${PR} timeout=${TIMEOUT}s poll=${POLL}s${PR_NC}" >&2
107
108 baseline="$(snapshot)"
109 echo -e "${PR_GRAY}[wait] baseline:${PR_NC}" >&2
110 echo "${baseline}" | sed 's/^/ /' >&2
111
112 start=$(date +%s)
113 while true; do
114 sleep "${POLL}"
115 now=$(date +%s)
116 elapsed=$(( now - start ))
117 if (( elapsed >= TIMEOUT )); then
118 echo -e "${PR_YELLOW}[wait] timeout after ${elapsed}s -- no new activity${PR_NC}" >&2
119 exit 124
120 fi
121 current="$(snapshot)"
122 if [[ "${current}" != "${baseline}" ]]; then
123 echo -e "${PR_GREEN}[wait] new activity detected after ${elapsed}s:${PR_NC}" >&2
124 # diff exits 1 when inputs differ (that's the success case here);
125 # set -euo pipefail would trip on it. Force exit 0 from the
126 # pipeline so the script proper can return 0 cleanly.
127 { diff <(printf '%s' "${baseline}") <(printf '%s' "${current}") || true; } \
128 | sed 's/^/ /' >&2
129 exit 0
130 fi
131 echo -e "${PR_GRAY}[wait] ${elapsed}s elapsed, no change yet...${PR_NC}" >&2
132 done