#!/usr/bin/env bash # pr-resolve — list and resolve PR review threads in batch. # # Usage: # scripts/pr-resolve list # List unresolved review threads, one per line, TAB-separated: # \t\t\t\t # # scripts/pr-resolve show # Print the full thread/comment body plus path, line, author, commit id, # and URL. Use before deciding how to answer a preview from list. # # scripts/pr-resolve reopen # Reopen an already-resolved review thread after an authorized later # change invalidates its resolution rationale. # # scripts/pr-resolve reply # scripts/pr-resolve reply --body-file # Reply to the thread starter, resolve the thread, and add a +1 # reaction in one call. Use this for both "fixed" replies and # pushback replies — the reply body conveys whether you agreed # or not. # # Owner/repo are derived from `gh repo view` in the current repo. # # Requires: gh (authenticated) and jq. set -euo pipefail usage() { sed -n '2,20p' "$0" | sed 's|^# \{0,1\}||' exit "${1:-1}" } [ $# -ge 1 ] || usage cmd=$1; shift case "$cmd" in -h | --help | help) usage 0 ;; esac if [[ "$cmd" == "reply" && ( $# -eq 4 || $# -eq 5 ) ]]; then if [[ "$2" == PRRT_* ]]; then echo "reply expects comment_id before thread_id; got a review thread ID in the comment_id position" >&2 exit 1 fi if [[ "$3" != PRRT_* ]]; then echo "reply expects thread_id after comment_id; got a non-review-thread ID in the thread_id position" >&2 exit 1 fi if [[ $# -eq 4 && "$4" == "--body-file" ]]; then echo "reply --body-file requires a path" >&2 exit 1 fi if [[ $# -eq 5 ]]; then [[ "$4" == "--body-file" ]] || usage [[ -f "$5" ]] || { echo "body file not found: $5" >&2; exit 1; } [[ -s "$5" ]] || { echo "body file is empty: $5" >&2; exit 1; } fi fi if [[ "$cmd" == "reopen" ]]; then [ $# -eq 2 ] || usage [[ "$2" == PRRT_* ]] || { echo "reopen expects a review thread ID as its second argument" >&2 exit 1 } fi read -r OWNER REPO < <(gh repo view --json owner,name --jq '"\(.owner.login) \(.name)"') case "$cmd" in list) [ $# -eq 1 ] || usage PR=$1 gh api graphql -f query=' query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { nodes { id isResolved path comments(first: 1) { nodes { databaseId author { login } body } } } } } } }' -f owner="$OWNER" -f repo="$REPO" -F pr="$PR" \ | jq -r '.data.repository.pullRequest.reviewThreads.nodes | map(select(.isResolved == false)) | .[] | "\(.id)\t\(.comments.nodes[0].databaseId)\t\(.comments.nodes[0].author.login)\t\(.path)\t\(.comments.nodes[0].body | gsub("\n"; " ") | .[0:120])"' ;; show) [ $# -eq 2 ] || usage PR=$1; TARGET=$2 case "$TARGET" in PRRT_*) gh api graphql -f query=' query($id: ID!) { node(id: $id) { ... on PullRequestReviewThread { id isResolved path line pullRequest { number } comments(first: 100) { nodes { databaseId author { login } body path line commit { oid } url createdAt } } } } }' -f id="$TARGET" \ | jq -r --argjson pr "$PR" ' .data.node as $thread | if $thread == null then error("thread not found") elif $thread.pullRequest.number != $pr then error("thread belongs to PR #\($thread.pullRequest.number), not #\($pr)") else "thread_id: \($thread.id)", "resolved: \($thread.isResolved)", "path: \($thread.path // "-")", "line: \($thread.line // "-")", "comments:", ($thread.comments.nodes[] | [ " comment_id: \(.databaseId)", " author: \(.author.login // "-")", " path: \(.path // $thread.path // "-")", " line: \(.line // "-")", " commit_id: \(.commit.oid // "-")", " html_url: \(.url // "-")", " created_at: \(.createdAt // "-")", " body:", (.body | split("\n") | map(" " + .) | join("\n")) ] | join("\n")) end' ;; *) gh api "repos/$OWNER/$REPO/pulls/comments/$TARGET" \ | jq -r --argjson pr "$PR" ' (.pull_request_url | capture("/pulls/(?[0-9]+)$").number | tonumber) as $comment_pr | if $comment_pr != $pr then error("comment belongs to PR #\($comment_pr), not #\($pr)") else "comment_id: \(.id)", "author: \(.user.login // "-")", "path: \(.path // "-")", "line: \(.line // "-")", "commit_id: \(.commit_id // "-")", "html_url: \(.html_url // "-")", "created_at: \(.created_at // "-")", "body:", (.body | split("\n") | map(" " + .) | join("\n")) end' ;; esac ;; reopen) PR=$1; THREAD_ID=$2 if ! THREAD_JSON=$(gh api graphql -f query=' query($id: ID!) { node(id: $id) { ... on PullRequestReviewThread { id isResolved pullRequest { number repository { nameWithOwner } } } } }' -f id="$THREAD_ID"); then echo "failed to fetch review thread $THREAD_ID for PR #$PR" >&2 exit 1 fi if ! jq -e --arg pr "$PR" --arg thread_id "$THREAD_ID" --arg repository "$OWNER/$REPO" ' .data.node as $thread | if $thread == null then error("review thread \($thread_id) not found on PR #\($pr)") elif (($thread.pullRequest.number // -1) | tostring) != $pr then error("thread belongs to PR #\($thread.pullRequest.number), not #\($pr)") elif (($thread.pullRequest.repository.nameWithOwner // "") != $repository) then ($thread.pullRequest.repository.nameWithOwner // "unknown") as $thread_repository | error("thread belongs to repository \($thread_repository), not \($repository)") elif $thread.isResolved != true then error("thread is not resolved") else true end' <<<"$THREAD_JSON" >/dev/null; then exit 1 fi if ! UNRESOLVE_JSON=$(gh api graphql -f query='mutation($t: ID!) { unresolveReviewThread(input: {threadId: $t}) { thread { isResolved } } }' \ -f t="$THREAD_ID"); then echo "failed to reopen review thread $THREAD_ID on PR #$PR" >&2 exit 1 fi if ! jq -e '.data.unresolveReviewThread.thread.isResolved == false' <<<"$UNRESOLVE_JSON" >/dev/null; then echo "GitHub returned an invalid reopen response for review thread $THREAD_ID on PR #$PR" >&2 exit 1 fi echo "ok $THREAD_ID" ;; reply) [[ $# -eq 4 || $# -eq 5 ]] || usage PR=$1; COMMENT_ID=$2; THREAD_ID=$3 if [[ $# -eq 5 ]]; then BODY=$(<"$5") else BODY=$4 fi gh api -X POST "repos/$OWNER/$REPO/pulls/$PR/comments/$COMMENT_ID/replies" \ -f body="$BODY" --silent gh api graphql -f query='mutation($t: ID!) { resolveReviewThread(input: {threadId: $t}) { thread { isResolved } } }' \ -f t="$THREAD_ID" --silent gh api -X POST "repos/$OWNER/$REPO/pulls/comments/$COMMENT_ID/reactions" \ -f content="+1" --silent echo "ok $THREAD_ID" ;; *) usage ;; esac