mirror of
https://github.com/EveryInc/compound-engineering-plugin.git
synced 2026-10-27 17:15:25 +01:00
177 lines
6.8 KiB
Bash
Executable File
177 lines
6.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -e
|
|
|
|
if [ $# -lt 1 ]; then
|
|
echo "Usage: get-pr-comments PR_NUMBER [OWNER/REPO]"
|
|
echo "Example: get-pr-comments 123"
|
|
echo "Example: get-pr-comments 123 EveryInc/cora"
|
|
exit 1
|
|
fi
|
|
|
|
PR_NUMBER=$1
|
|
|
|
if [ -n "$2" ]; then
|
|
OWNER=$(echo "$2" | cut -d/ -f1)
|
|
REPO=$(echo "$2" | cut -d/ -f2)
|
|
else
|
|
# `|| true` is load-bearing: under `set -e`, a failed command substitution
|
|
# in an assignment aborts the script immediately. Run outside a git repo,
|
|
# `gh repo view` exits 1, its stderr is swallowed by `2>/dev/null`, and the
|
|
# script would die here with rc=1 and no output -- making the friendly error
|
|
# below unreachable. Keep `|| true` so detection failure falls through to it.
|
|
OWNER=$(gh repo view --json owner -q .owner.login 2>/dev/null || true)
|
|
REPO=$(gh repo view --json name -q .name 2>/dev/null || true)
|
|
fi
|
|
|
|
if [ -z "$OWNER" ] || [ -z "$REPO" ]; then
|
|
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
|
|
exit 1
|
|
fi
|
|
|
|
# Output is a JSON object with these keys:
|
|
# pending_review - node ID of the viewer's own unsubmitted (PENDING) review
|
|
# on this PR, or null. Replies posted while one exists get
|
|
# silently absorbed into that draft and stay invisible
|
|
# until it is submitted, so the modes must stop before the
|
|
# reply loop when this is non-null. Costs no extra request:
|
|
# the reviews query below already selects `state`, and a
|
|
# PENDING review is only visible to its own author.
|
|
# review_threads - unresolved inline review threads, edge-wrapped with the
|
|
# root numeric REST comment ID used for direct replies:
|
|
# [{ root_comment_id, node: { id, isResolved, ... } }]
|
|
# pr_comments - non-empty top-level PR conversation comments
|
|
# review_bodies - non-empty review submission bodies
|
|
# pr_author - the PR author's login, or null
|
|
# viewer - the acting account's login, or null
|
|
#
|
|
# Pagination (issue #798): each top-level connection -- reviewThreads,
|
|
# comments, reviews -- is fetched in its own paginated query because
|
|
# `gh api graphql --paginate` only follows the outermost pageInfo per
|
|
# response. Combining them into one query (as this script previously did)
|
|
# silently dropped everything past page 1 on long-lived PRs and made the
|
|
# skill report "0 of 0 resolved" while real findings sat unanswered.
|
|
# Per-thread inline `comments` are fetched up to 100 per thread without
|
|
# follow-up pagination; threads that exceed 100 comments are rare and out of
|
|
# scope for this fix.
|
|
#
|
|
# Content, identity, and surface are evidence for the resolver, not deterministic
|
|
# exclusions. CI/status bots can carry actionable setup or posting failures, and
|
|
# automation identities can transport human-authored reviews -- and an author
|
|
# posting on their own PR is the ordinary way a human asks an agent-opened PR
|
|
# for a change (#1435 eval: every run silently dropped "please rename check_id"
|
|
# because this fetch excluded it before the skill could judge it). So the only
|
|
# exclusion here is a blank body; `pr_author` and `viewer` are reported so the
|
|
# resolver can weigh identity as evidence, including recognizing its own prior
|
|
# replies as already-handled.
|
|
|
|
tmp_threads=$(mktemp "${TMPDIR:-/tmp}/ce-pr-threads.XXXXXX")
|
|
tmp_comments=$(mktemp "${TMPDIR:-/tmp}/ce-pr-comments.XXXXXX")
|
|
tmp_reviews=$(mktemp "${TMPDIR:-/tmp}/ce-pr-reviews.XXXXXX")
|
|
trap 'rm -f "$tmp_threads" "$tmp_comments" "$tmp_reviews"' EXIT
|
|
|
|
gh api graphql --paginate --slurp \
|
|
-f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" \
|
|
-f query='
|
|
query Threads($owner: String!, $repo: String!, $pr: Int!, $endCursor: String) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $pr) {
|
|
author { login }
|
|
reviewThreads(first: 100, after: $endCursor) {
|
|
nodes {
|
|
id
|
|
isResolved
|
|
isOutdated
|
|
path
|
|
line
|
|
originalLine
|
|
startLine
|
|
originalStartLine
|
|
comments(first: 100) {
|
|
nodes {
|
|
id
|
|
author { login }
|
|
body
|
|
createdAt
|
|
url
|
|
}
|
|
}
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}' > "$tmp_threads"
|
|
|
|
gh api graphql --paginate --slurp \
|
|
-f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" \
|
|
-f query='
|
|
query Comments($owner: String!, $repo: String!, $pr: Int!, $endCursor: String) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $pr) {
|
|
comments(first: 100, after: $endCursor) {
|
|
nodes {
|
|
id
|
|
author { login }
|
|
body
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}' > "$tmp_comments"
|
|
|
|
gh api graphql --paginate --slurp \
|
|
-f owner="$OWNER" -f repo="$REPO" -F pr="$PR_NUMBER" \
|
|
-f query='
|
|
query Reviews($owner: String!, $repo: String!, $pr: Int!, $endCursor: String) {
|
|
viewer { login }
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequest(number: $pr) {
|
|
reviews(first: 100, after: $endCursor) {
|
|
nodes {
|
|
id
|
|
author { login }
|
|
body
|
|
state
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
}
|
|
}' > "$tmp_reviews"
|
|
|
|
# Resolution semantics: `isOutdated` means the diff hunk around the comment
|
|
# has shifted since the thread was opened -- not that the reviewer concern
|
|
# was addressed. Resolution state is the only authoritative signal; outdated
|
|
# threads are still surfaced (with their isOutdated flag intact) so the
|
|
# resolver can factor in that the referenced line may have moved.
|
|
jq -n \
|
|
--slurpfile threads "$tmp_threads" \
|
|
--slurpfile comments "$tmp_comments" \
|
|
--slurpfile reviews "$tmp_reviews" '
|
|
($threads[0][0].data.repository.pullRequest.author) as $author |
|
|
[$threads[0][].data.repository.pullRequest.reviewThreads.nodes[]] as $all_threads |
|
|
[$comments[0][].data.repository.pullRequest.comments.nodes[]] as $all_comments |
|
|
[$reviews[0][].data.repository.pullRequest.reviews.nodes[]] as $all_reviews |
|
|
[$all_threads[] | select(.isResolved == false)] as $unresolved |
|
|
($reviews[0][0].data.viewer.login) as $viewer |
|
|
{
|
|
pending_review: (first($all_reviews[]
|
|
| select(.state == "PENDING")
|
|
| select(($viewer == null) or (.author.login == $viewer))
|
|
| .id) // null),
|
|
review_threads: [$unresolved[] | {
|
|
node: .,
|
|
root_comment_id: (.comments.nodes[0].url
|
|
| capture("discussion_r(?<id>[0-9]+)").id
|
|
| tonumber)
|
|
}],
|
|
pr_comments: [$all_comments[]
|
|
| select((.body // "") | test("^\\s*$") | not)],
|
|
review_bodies: [$all_reviews[]
|
|
| select((.body // "") | test("^\\s*$") | not)],
|
|
pr_author: ($author.login // null),
|
|
viewer: ($viewer // null)
|
|
}'
|