From b8aa527fffce932a4989237f15bb10dd5fd6c8b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:10:02 +0000 Subject: [PATCH] Read the state marker and PR lists without result limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh pr view --json comments returns the first 100 comments, so on a busy PR the state marker could fall outside the window and the resume would give up. Read the comments through gh api graphql --paginate, which walks every page, and keep the last marker among our own comments. The two gh pr list calls had the same shape of problem at their default limit of 30: a silently truncated fan-out, or a missed conflicted sibling letting the shared base branch be deleted. List pulls through gh api --paginate so every page is read. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JHvKryT4QUpHYdNq9YEQxX --- tests/mock_gh.sh | 14 ++++---------- tests/test_conflict_resolution_resume.sh | 15 ++++++++------- update-pr-stack.sh | 23 +++++++++++++++++++---- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/tests/mock_gh.sh b/tests/mock_gh.sh index 3114109..4b71641 100755 --- a/tests/mock_gh.sh +++ b/tests/mock_gh.sh @@ -3,16 +3,10 @@ # Mock gh CLI for unit tests. # Only direct children are queried now (no recursive updates of indirect children). -if [[ "$1" == "pr" && "$2" == "list" ]]; then - # Parse the --base argument to determine which PRs to return - base="" - for ((i=1; i<=$#; i++)); do - if [[ "${!i}" == "--base" ]]; then - next=$((i+1)) - base="${!next}" - fi - done - +if [[ "$1" == "api" && "$2" == repos/*"/pulls?base="* ]]; then + # Open PRs based on a branch (already --jq filtered to " "). + base="${2#*pulls\?base=}" + base="${base%%&*}" if [[ "$base" == "feature1" ]]; then # feature2 is a direct child of feature1 (PR #2) echo '2 feature2' diff --git a/tests/test_conflict_resolution_resume.sh b/tests/test_conflict_resolution_resume.sh index 415083c..4fd02b9 100644 --- a/tests/test_conflict_resolution_resume.sh +++ b/tests/test_conflict_resolution_resume.sh @@ -21,7 +21,7 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); } # Build a configurable gh mock in a temp dir. It records every invocation to # $CALLS and is driven by env vars set per scenario: # MOCK_LABELS newline-separated labels returned by `pr view --json labels` -# MOCK_COMMENTS_FILE file whose contents are returned by `pr view --json comments` +# MOCK_COMMENTS_FILE file served as the body of our own PR comments # The PR's base branch is not mocked: the script must take it from PR_BASE # (event payload), so a baseRefName query is an unhandled call and fails. make_mock_gh() { @@ -33,18 +33,18 @@ echo "gh $*" >> "$CALLS" if [[ "$1 $2" == "pr view" ]]; then case "$*" in *--json\ labels*) printf '%s\n' "${MOCK_LABELS:-}";; - *--json\ comments*) - # The comments file stands for our own comments only, so the query - # must restrict itself to those. - [[ "$*" == *viewerDidAuthor* ]] || { echo "comments query must filter by viewerDidAuthor" >&2; exit 1; } - cat "${MOCK_COMMENTS_FILE:-/dev/null}";; *) echo "unhandled pr view: $*" >&2; exit 1;; esac +elif [[ "$1 $2" == "api graphql" ]]; then + # The comments file stands for our own comments only, so the query must + # restrict itself to those. + [[ "$*" == *viewerDidAuthor* ]] || { echo "comments query must filter by viewerDidAuthor" >&2; exit 1; } + cat "${MOCK_COMMENTS_FILE:-/dev/null}" elif [[ "$1 $2" == "pr comment" ]]; then cat >/dev/null # consume the -F - body elif [[ "$1 $2" == "pr edit" ]]; then : -elif [[ "$1 $2" == "pr list" ]]; then +elif [[ "$1" == "api" ]]; then : # no sibling conflicts elif [[ "$1 $2" == "label create" ]]; then : @@ -93,6 +93,7 @@ setup_repo() { run_resume() { env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \ + GITHUB_REPOSITORY=tester/repo \ GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \ MOCK_LABELS="$MOCK_LABELS" \ MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \ diff --git a/update-pr-stack.sh b/update-pr-stack.sh index e3fec22..02f8697 100755 --- a/update-pr-stack.sh +++ b/update-pr-stack.sh @@ -12,6 +12,7 @@ # PR_BRANCH - The head branch of the PR being resumed # PR_NUMBER - Its PR number, from the event payload # PR_BASE - Its base branch, from the event payload +# GITHUB_REPOSITORY - "owner/repo", provided by Actions # # Design note: # This script aims to output a transcript of "plain" git/gh commands that a @@ -44,8 +45,19 @@ format_state_marker() { read_state_marker() { local PR_NUMBER="$1" local BODIES - if ! BODIES=$(gh pr view "$PR_NUMBER" --json comments \ - --jq '.comments[] | select(.viewerDidAuthor) | .body'); then + if ! BODIES=$(gh api graphql --paginate \ + -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" \ + -F number="$PR_NUMBER" -f query=' + query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + comments(first: 100, after: $endCursor) { + pageInfo { hasNextPage endCursor } + nodes { viewerDidAuthor body } + } + } + } + }' --jq '.data.repository.pullRequest.comments.nodes[] | select(.viewerDidAuthor) | .body'); then echo "Error: could not read comments of PR #$PR_NUMBER" >&2 exit 1 fi @@ -139,7 +151,8 @@ is_rebase_merge() { # Echoes " " for each open PR based on the merged branch. list_child_prs() { - log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"' + log_cmd gh api "repos/{owner}/{repo}/pulls?base=$MERGED_BRANCH&state=open&per_page=100" \ + --paginate --jq '.[] | "\(.number) \(.head.ref)"' } # Args: head branch, base branch, PR number. git commands use the branch; gh @@ -255,7 +268,8 @@ has_sibling_conflicts() { # Find all open PRs with the conflict label that are based on BASE_BRANCH local CONFLICTED_SIBLINGS - CONFLICTED_SIBLINGS=$(gh pr list --base "$BASE_BRANCH" --label "$CONFLICT_LABEL" --json headRefName --jq '.[].headRefName' 2>/dev/null || echo "") + CONFLICTED_SIBLINGS=$(gh api "repos/{owner}/{repo}/pulls?base=$BASE_BRANCH&state=open&per_page=100" \ + --paginate --jq ".[] | select(any(.labels[]; .name == \"$CONFLICT_LABEL\")) | .head.ref" 2>/dev/null || echo "") for SIBLING in $CONFLICTED_SIBLINGS; do if [[ "$SIBLING" != "$EXCLUDE_BRANCH" ]]; then @@ -281,6 +295,7 @@ continue_after_resolution() { check_env_var "PR_BRANCH" check_env_var "PR_NUMBER" check_env_var "PR_BASE" + check_env_var "GITHUB_REPOSITORY" echo "Checking if PR #$PR_NUMBER ($PR_BRANCH) needs continuation after conflict resolution..."