| 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 |