Skip to content

Commit 540ae65

Browse files
Phlogistiqueclaude
andauthored
Read the state marker and PR lists without result limits (#48)
Reworked from the original "raise the limit to 100", which only traded one arbitrary cutoff for another. The real problem was the state-marker read: `gh pr view --json comments` returns the first 100 comments, so on a busy PR the marker could fall outside the window and the resume would give up. `read_state_marker` now uses `gh api graphql --paginate` — gh walks the cursor itself via `$endCursor`/`pageInfo` — filtered to our own comments (`viewerDidAuthor`), keeping the last marker. Every comment is seen, no hand-rolled loop. The two `gh pr list` calls had the same shape of problem at their default limit of 30 (silently truncated fan-out; a missed conflicted sibling letting the shared base branch be deleted), so they now go through `gh api --paginate` too. `GITHUB_REPOSITORY` (always set in Actions) is now required in conflict-resolved mode, since GraphQL has no `{owner}/{repo}` placeholder support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JHvKryT4QUpHYdNq9YEQxX Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab4f3bc commit 540ae65

3 files changed

Lines changed: 31 additions & 21 deletions

File tree

tests/mock_gh.sh

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,10 @@
33
# Mock gh CLI for unit tests.
44
# Only direct children are queried now (no recursive updates of indirect children).
55

6-
if [[ "$1" == "pr" && "$2" == "list" ]]; then
7-
# Parse the --base argument to determine which PRs to return
8-
base=""
9-
for ((i=1; i<=$#; i++)); do
10-
if [[ "${!i}" == "--base" ]]; then
11-
next=$((i+1))
12-
base="${!next}"
13-
fi
14-
done
15-
6+
if [[ "$1" == "api" && "$2" == repos/*"/pulls?base="* ]]; then
7+
# Open PRs based on a branch (already --jq filtered to "<number> <head>").
8+
base="${2#*pulls\?base=}"
9+
base="${base%%&*}"
1610
if [[ "$base" == "feature1" ]]; then
1711
# feature2 is a direct child of feature1 (PR #2)
1812
echo '2 feature2'

tests/test_conflict_resolution_resume.sh

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); }
2121
# Build a configurable gh mock in a temp dir. It records every invocation to
2222
# $CALLS and is driven by env vars set per scenario:
2323
# MOCK_LABELS newline-separated labels returned by `pr view --json labels`
24-
# MOCK_COMMENTS_FILE file whose contents are returned by `pr view --json comments`
24+
# MOCK_COMMENTS_FILE file served as the body of our own PR comments
2525
# The PR's base branch is not mocked: the script must take it from PR_BASE
2626
# (event payload), so a baseRefName query is an unhandled call and fails.
2727
make_mock_gh() {
@@ -33,18 +33,18 @@ echo "gh $*" >> "$CALLS"
3333
if [[ "$1 $2" == "pr view" ]]; then
3434
case "$*" in
3535
*--json\ labels*) printf '%s\n' "${MOCK_LABELS:-}";;
36-
*--json\ comments*)
37-
# The comments file stands for our own comments only, so the query
38-
# must restrict itself to those.
39-
[[ "$*" == *viewerDidAuthor* ]] || { echo "comments query must filter by viewerDidAuthor" >&2; exit 1; }
40-
cat "${MOCK_COMMENTS_FILE:-/dev/null}";;
4136
*) echo "unhandled pr view: $*" >&2; exit 1;;
4237
esac
38+
elif [[ "$1 $2" == "api graphql" ]]; then
39+
# The comments file stands for our own comments only, so the query must
40+
# restrict itself to those.
41+
[[ "$*" == *viewerDidAuthor* ]] || { echo "comments query must filter by viewerDidAuthor" >&2; exit 1; }
42+
cat "${MOCK_COMMENTS_FILE:-/dev/null}"
4343
elif [[ "$1 $2" == "pr comment" ]]; then
4444
cat >/dev/null # consume the -F - body
4545
elif [[ "$1 $2" == "pr edit" ]]; then
4646
:
47-
elif [[ "$1 $2" == "pr list" ]]; then
47+
elif [[ "$1" == "api" ]]; then
4848
: # no sibling conflicts
4949
elif [[ "$1 $2" == "label create" ]]; then
5050
:
@@ -93,6 +93,7 @@ setup_repo() {
9393

9494
run_resume() {
9595
env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \
96+
GITHUB_REPOSITORY=tester/repo \
9697
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
9798
MOCK_LABELS="$MOCK_LABELS" \
9899
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \

update-pr-stack.sh

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# PR_BRANCH - The head branch of the PR being resumed
1313
# PR_NUMBER - Its PR number, from the event payload
1414
# PR_BASE - Its base branch, from the event payload
15+
# GITHUB_REPOSITORY - "owner/repo", provided by Actions
1516
#
1617
# Design note:
1718
# This script aims to output a transcript of "plain" git/gh commands that a
@@ -44,8 +45,19 @@ format_state_marker() {
4445
read_state_marker() {
4546
local PR_NUMBER="$1"
4647
local BODIES
47-
if ! BODIES=$(gh pr view "$PR_NUMBER" --json comments \
48-
--jq '.comments[] | select(.viewerDidAuthor) | .body'); then
48+
if ! BODIES=$(gh api graphql --paginate \
49+
-F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" \
50+
-F number="$PR_NUMBER" -f query='
51+
query($owner: String!, $repo: String!, $number: Int!, $endCursor: String) {
52+
repository(owner: $owner, name: $repo) {
53+
pullRequest(number: $number) {
54+
comments(first: 100, after: $endCursor) {
55+
pageInfo { hasNextPage endCursor }
56+
nodes { viewerDidAuthor body }
57+
}
58+
}
59+
}
60+
}' --jq '.data.repository.pullRequest.comments.nodes[] | select(.viewerDidAuthor) | .body'); then
4961
echo "Error: could not read comments of PR #$PR_NUMBER" >&2
5062
exit 1
5163
fi
@@ -139,7 +151,8 @@ is_rebase_merge() {
139151

140152
# Echoes "<number> <head branch>" for each open PR based on the merged branch.
141153
list_child_prs() {
142-
log_cmd gh pr list --base "$MERGED_BRANCH" --json number,headRefName --jq '.[] | "\(.number) \(.headRefName)"'
154+
log_cmd gh api "repos/{owner}/{repo}/pulls?base=$MERGED_BRANCH&state=open&per_page=100" \
155+
--paginate --jq '.[] | "\(.number) \(.head.ref)"'
143156
}
144157

145158
# A failed git merge does not always leave a merge in progress: when the ref to
@@ -244,7 +257,8 @@ has_sibling_conflicts() {
244257

245258
# Find all open PRs with the conflict label that are based on BASE_BRANCH
246259
local CONFLICTED_SIBLINGS
247-
CONFLICTED_SIBLINGS=$(gh pr list --base "$BASE_BRANCH" --label "$CONFLICT_LABEL" --json headRefName --jq '.[].headRefName' 2>/dev/null || echo "")
260+
CONFLICTED_SIBLINGS=$(gh api "repos/{owner}/{repo}/pulls?base=$BASE_BRANCH&state=open&per_page=100" \
261+
--paginate --jq ".[] | select(any(.labels[]; .name == \"$CONFLICT_LABEL\")) | .head.ref" 2>/dev/null || echo "")
248262

249263
for SIBLING in $CONFLICTED_SIBLINGS; do
250264
if [[ "$SIBLING" != "$EXCLUDE_BRANCH" ]]; then
@@ -270,6 +284,7 @@ continue_after_resolution() {
270284
check_env_var "PR_BRANCH"
271285
check_env_var "PR_NUMBER"
272286
check_env_var "PR_BASE"
287+
check_env_var "GITHUB_REPOSITORY"
273288

274289
echo "Checking if PR #$PR_NUMBER ($PR_BRANCH) needs continuation after conflict resolution..."
275290

0 commit comments

Comments
 (0)