#!/usr/bin/env bash # pr-state — emit a raw JSON snapshot of PR CI and review state. # # Usage: # scripts/pr-state # # Output: # JSON object with: # - pr: { number, branch } # - checks: [{ type, name, workflow, status, conclusion, details_url, target_url, run_id, job_id }] # - review_threads: [{ thread_id, is_resolved, path, comment_id, author, body_preview }] # - unresolved_review_thread_count: # - reviews: [{ author, state, submitted_at, html_url }] # - issue_comments: [{ author, body, created_at, url }] # - errors: [{ source, message }] # # Requires: gh (authenticated), jq. set -euo pipefail usage() { sed -n '2,15p' "$0" | sed 's|^# \{0,1\}||' exit "${1:-1}" } [ $# -eq 1 ] || usage PR=$1 errors='[]' pr_json='{}' checks_json='[]' review_threads_json='[]' reviews_json='[]' issue_comments_json='[]' unresolved_review_thread_count=null add_error() { local source=$1 local message=$2 errors="$(jq -cn --argjson prev "$errors" --arg source "$source" --arg message "$message" \ '$prev + [{source: $source, message: $message}]')" } gh_json() { env -u GH_DEBUG -u GITHUB_DEBUG -u GH_TRACE -u GITHUB_TRACE \ GH_PAGER=cat GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 \ gh "$@" 2>/dev/null } extract_checks_json() { local status_json=$1 jq -cn --argjson checks_in "$status_json" ' def lower_or_null: if . == null or . == "" then null else ascii_downcase end; def extract($re; $field): if . == null then null else ((capture($re)? | .[$field]) // null) end; $checks_in | map({ type: (.["__typename"] // null), name: (.name // .context // "unknown"), workflow: (.workflowName // .workflow // .checkSuite.workflowRun.workflow.name // null), status: ((.status // .state // "unknown") | ascii_downcase), conclusion: ((.conclusion // .state // "") | lower_or_null), details_url: (.detailsUrl // null), target_url: (.targetUrl // null), run_id: ((.detailsUrl // null) | extract("/actions/runs/(?[0-9]+)"; "id")), job_id: ((.detailsUrl // null) | extract("/job/(?[0-9]+)"; "id")) }) ' } extract_issue_comments_json() { local comments_json=$1 jq -cn --argjson comments "$comments_json" ' $comments | map({ author: (.author.login // "unknown"), body: (.body // ""), created_at: (.createdAt // .created_at // null), url: (.url // null) }) ' } extract_reviews_json() { local reviews_api_json=$1 jq -cn --argjson reviews "$reviews_api_json" ' $reviews | map({ author: (.user.login // "unknown"), state: ((.state // "unknown") | ascii_downcase), submitted_at: (.submitted_at // null), html_url: (.html_url // null) }) ' } extract_review_threads_json() { local threads_nodes_json=$1 jq -cn --argjson threads "$threads_nodes_json" ' $threads | map({ thread_id: .id, is_resolved: (.isResolved // false), path: (.path // ""), comment_id: (.comments.nodes[0].databaseId // null), author: (.comments.nodes[0].author.login // "unknown"), body_preview: ((.comments.nodes[0].body // "") | gsub("\n"; " ") | .[0:120]) }) ' } fetch_review_threads_nodes_json() { local owner=$1 local repo=$2 local pr_number=$3 local cursor='' local nodes='[]' while :; do local cursor_args=() if [[ -n "$cursor" ]]; then cursor_args=(-f cursor="$cursor") fi local page_json if ! page_json="$(gh_json api graphql \ -f query=' query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100, after: $cursor) { pageInfo { hasNextPage endCursor } nodes { id isResolved path comments(first: 1) { nodes { databaseId author { login } body } } } } } } }' \ -f owner="$owner" -f repo="$repo" -F pr="$pr_number" "${cursor_args[@]}")"; then return 1 fi local page_nodes page_nodes="$(jq -c '.data.repository.pullRequest.reviewThreads.nodes // []' <<<"$page_json")" nodes="$(jq -cn --argjson prev "$nodes" --argjson page "$page_nodes" '$prev + $page')" local has_next has_next="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage // false' <<<"$page_json")" if [[ "$has_next" != "true" ]]; then break fi cursor="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // ""' <<<"$page_json")" if [[ -z "$cursor" ]]; then add_error "review_threads" "pagination stopped: missing endCursor" break fi done printf '%s\n' "$nodes" } repo_owner='' repo_name='' if repo_json="$(gh_json repo view --json owner,name)"; then repo_owner="$(jq -r '.owner.login // ""' <<<"$repo_json")" repo_name="$(jq -r '.name // ""' <<<"$repo_json")" else add_error "repo" "gh repo view failed" fi comments_json='[]' status_json='[]' resolved_pr_number='' if pr_view_json="$(gh_json pr view "$PR" --json number,headRefName,statusCheckRollup,comments,url)"; then resolved_pr_number="$(jq -r '.number // ""' <<<"$pr_view_json")" if pr_url_owner="$(jq -r '.url | capture("github.com/(?[^/]+)/(?[^/]+)/pull/[0-9]+").owner // ""' <<<"$pr_view_json" 2>/dev/null)" && [[ -n "$pr_url_owner" ]]; then repo_owner="$pr_url_owner" repo_name="$(jq -r '.url | capture("github.com/(?[^/]+)/(?[^/]+)/pull/[0-9]+").repo // ""' <<<"$pr_view_json")" fi pr_json="$(jq -c '{number: .number, branch: (.headRefName // "unknown")}' <<<"$pr_view_json")" comments_json="$(jq -c '.comments // []' <<<"$pr_view_json")" status_json="$(jq -c '.statusCheckRollup // []' <<<"$pr_view_json")" checks_json="$(extract_checks_json "$status_json")" issue_comments_json="$(extract_issue_comments_json "$comments_json")" else if [[ "$PR" =~ ^[0-9]+$ ]]; then pr_json="$(jq -cn --argjson number "$PR" '{number: $number, branch: "unknown"}')" else pr_json="$(jq -cn --arg branch "$PR" '{number: null, branch: $branch}')" fi add_error "pr_view" "gh pr view failed" fi if [[ -n "$repo_owner" && -n "$repo_name" && -n "$resolved_pr_number" ]]; then if reviews_pages_json="$(gh_json api --paginate -X GET "repos/$repo_owner/$repo_name/pulls/$resolved_pr_number/reviews")"; then reviews_api_json="$(jq -cs 'add // []' <<<"$reviews_pages_json")" reviews_json="$(extract_reviews_json "$reviews_api_json")" else add_error "reviews" "gh api pulls reviews failed" fi else add_error "reviews" "skipped: resolved PR identity unavailable" fi if [[ -n "$repo_owner" && -n "$repo_name" && -n "$resolved_pr_number" ]]; then if threads_nodes_json="$(fetch_review_threads_nodes_json "$repo_owner" "$repo_name" "$resolved_pr_number")"; then review_threads_json="$(extract_review_threads_json "$threads_nodes_json")" unresolved_review_thread_count="$(jq -r '[.[] | select(.is_resolved == false)] | length' <<<"$review_threads_json")" else add_error "review_threads" "gh api graphql reviewThreads failed" fi elif [[ -z "$repo_owner" || -z "$repo_name" ]]; then add_error "review_threads" "skipped: repo lookup failed" elif [[ -z "$resolved_pr_number" ]]; then add_error "review_threads" "skipped: resolved PR identity unavailable" fi jq -cn \ --argjson pr "$pr_json" \ --argjson checks "$checks_json" \ --argjson review_threads "$review_threads_json" \ --argjson reviews "$reviews_json" \ --argjson issue_comments "$issue_comments_json" \ --argjson unresolved_review_thread_count "$unresolved_review_thread_count" \ --argjson errors "$errors" \ '{ pr: $pr, checks: $checks, review_threads: $review_threads, unresolved_review_thread_count: $unresolved_review_thread_count, reviews: $reviews, issue_comments: $issue_comments, errors: $errors }'