Files
kandev/scripts/pr-state
T
2026-06-11 22:27:06 +01:00

246 lines
8.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# pr-state — emit a raw JSON snapshot of PR CI and review state.
#
# Usage:
# scripts/pr-state <PR>
#
# 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: <N>
# - 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/(?<id>[0-9]+)"; "id")),
job_id: ((.detailsUrl // null) | extract("/job/(?<id>[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/(?<owner>[^/]+)/(?<repo>[^/]+)/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/(?<owner>[^/]+)/(?<repo>[^/]+)/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
}'