Skills › Compound Engineering › ce-resolve-pr-feedback › get-pr-comments
get-pr-comments
176 lines · skills/ce-resolve-pr-feedback/scripts/get-pr-comments at 3449230
1#!/usr/bin/env bash
3set -e
5if [ $# -lt 1 ]; then
6 echo "Usage: get-pr-comments PR_NUMBER [OWNER/REPO]"
7 echo "Example: get-pr-comments 123"
8 echo "Example: get-pr-comments 123 EveryInc/cora"
9 exit 1
10fi
12PR_NUMBER=$1
14if [ -n "$2" ]; then
15 OWNER=$(echo "$2" | cut -d/ -f1)
16 REPO=$(echo "$2" | cut -d/ -f2)
17else
18 # `|| true` is load-bearing: under `set -e`, a failed command substitution
19 # in an assignment aborts the script immediately. Run outside a git repo,
20 # `gh repo view` exits 1, its stderr is swallowed by `2>/dev/null`, and the
21 # script would die here with rc=1 and no output -- making the friendly error
22 # below unreachable. Keep `|| true` so detection failure falls through to it.
23 OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null || true)
24 REPO=$(gh repo view --json name -q .name 2>/dev/null || true)
25fi
27if [ -z "$OWNER" ] || [ -z "$REPO" ]; then
28 echo "Error: could not resolve owner/repo. Run get-pr-comments from inside the target git repository, or pass OWNER/REPO as the second argument (e.g., get-pr-comments $PR_NUMBER EveryInc/cora)." >&2
29 exit 1
30fi
32# Output is a JSON object with these keys:
33# pending_review - node ID of the viewer's own unsubmitted (PENDING) review
34# on this PR, or null. Replies posted while one exists get
35# silently absorbed into that draft and stay invisible
36# until it is submitted, so the modes must stop before the
37# reply loop when this is non-null. Costs no extra request:
38# the reviews query below already selects `state`, and a
39# PENDING review is only visible to its own author.
40# review_threads - unresolved inline review threads, edge-wrapped with the
41# root numeric REST comment ID used for direct replies:
42# [{ root_comment_id, node: { id, isResolved, ... } }]
43# pr_comments - non-empty top-level PR conversation comments
44# review_bodies - non-empty review submission bodies
45# pr_author - the PR author's login, or null
46# viewer - the acting account's login, or null
47#
48# Pagination (issue #798): each top-level connection -- reviewThreads,
49# comments, reviews -- is fetched in its own paginated query because
50# `gh api graphql --paginate` only follows the outermost pageInfo per
51# response. Combining them into one query (as this script previously did)
52# silently dropped everything past page 1 on long-lived PRs and made the
53# skill report "0 of 0 resolved" while real findings sat unanswered.
54# Per-thread inline `comments` are fetched up to 100 per thread without
55# follow-up pagination; threads that exceed 100 comments are rare and out of
56# scope for this fix.
57#
58# Content, identity, and surface are evidence for the resolver, not deterministic
59# exclusions. CI/status bots can carry actionable setup or posting failures, and
60# automation identities can transport human-authored reviews -- and an author
61# posting on their own PR is the ordinary way a human asks an agent-opened PR
62# for a change (#1435 eval: every run silently dropped "please rename check_id"
63# because this fetch excluded it before the skill could judge it). So the only
64# exclusion here is a blank body; `pr_author` and `viewer` are reported so the
65# resolver can weigh identity as evidence, including recognizing its own prior
66# replies as already-handled.
68tmp_threads=$(mktemp "${TMPDIR:-/tmp}/ce-pr-threads.XXXXXX")
69tmp_comments=$(mktemp "${TMPDIR:-/tmp}/ce-pr-comments.XXXXXX")
70tmp_reviews=$(mktemp "${TMPDIR:-/tmp}/ce-pr-reviews.XXXXXX")
71trap 'rm -f "$tmp_threads" "$tmp_comments" "$tmp_reviews"' EXIT
73gh api graphql --paginate --slurp \
74 -f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" \
75 -f query='
76query Threads($owner: String!, $repo: String!, $pr: Int!, $endCursor: String) {
77 repository(owner: $owner, name: $repo) {
78 pullRequest(number: $pr) {
79 author { login }
80 reviewThreads(first: 100, after: $endCursor) {
81 nodes {
82 id
83 isResolved
84 isOutdated
85 path
86 line
87 originalLine
88 startLine
89 originalStartLine
90 comments(first: 100) {
91 nodes {
92 id
93 author { login }
94 body
95 createdAt
96 url
97 }
98 }
99 }
100 pageInfo { hasNextPage endCursor }
101 }
102 }
103 }
104}' > "$tmp_threads"
106gh api graphql --paginate --slurp \
107 -f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" \
108 -f query='
109query Comments($owner: String!, $repo: String!, $pr: Int!, $endCursor: String) {
110 repository(owner: $owner, name: $repo) {
111 pullRequest(number: $pr) {
112 comments(first: 100, after: $endCursor) {
113 nodes {
114 id
115 author { login }
116 body
117 }
118 pageInfo { hasNextPage endCursor }
119 }
120 }
121 }
122}' > "$tmp_comments"
124gh api graphql --paginate --slurp \
125 -f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" \
126 -f query='
127query Reviews($owner: String!, $repo: String!, $pr: Int!, $endCursor: String) {
128 viewer { login }
129 repository(owner: $owner, name: $repo) {
130 pullRequest(number: $pr) {
131 reviews(first: 100, after: $endCursor) {
132 nodes {
133 id
134 author { login }
135 body
136 state
137 }
138 pageInfo { hasNextPage endCursor }
139 }
140 }
141 }
142}' > "$tmp_reviews"
144# Resolution semantics: `isOutdated` means the diff hunk around the comment
145# has shifted since the thread was opened -- not that the reviewer concern
146# was addressed. Resolution state is the only authoritative signal; outdated
147# threads are still surfaced (with their isOutdated flag intact) so the
148# resolver can factor in that the referenced line may have moved.
149jq -n \
150 --slurpfile threads "$tmp_threads" \
151 --slurpfile comments "$tmp_comments" \
152 --slurpfile reviews "$tmp_reviews" '
153 ($threads[0][0].data.repository.pullRequest.author) as $author |
154 [$threads[0][].data.repository.pullRequest.reviewThreads.nodes[]] as $all_threads |
155 [$comments[0][].data.repository.pullRequest.comments.nodes[]] as $all_comments |
156 [$reviews[0][].data.repository.pullRequest.reviews.nodes[]] as $all_reviews |
157 [$all_threads[] | select(.isResolved == false)] as $unresolved |
158 ($reviews[0][0].data.viewer.login) as $viewer |
159 {
160 pending_review: (first($all_reviews[]
161 | select(.state == "PENDING")
162 | select(($viewer == null) or (.author.login == $viewer))
163 | .id) // null),
164 review_threads: [$unresolved[] | {
165 node: .,
166 root_comment_id: (.comments.nodes[0].url
167 | capture("discussion_r(?<id>[0-9]+)").id
168 | tonumber)
169 }],
170 pr_comments: [$all_comments[]
171 | select((.body // "") | test("^\\s*$") | not)],
172 review_bodies: [$all_reviews[]
173 | select((.body // "") | test("^\\s*$") | not)],
174 pr_author: ($author.login // null),
175 viewer: ($viewer // null)
176 }'