Skip to content

Commit aaa135a

Browse files
committed
Read the state marker and PR lists without result limits
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 GraphQL instead, paging newest-first and stopping at the first of our comments that carries a marker; no amount of later discussion can hide it. 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
1 parent dbc48ba commit aaa135a

3 files changed

Lines changed: 50 additions & 26 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: 10 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,20 @@ 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+
jq -Rs '{data: {repository: {pullRequest: {comments: {
43+
pageInfo: {hasPreviousPage: false, startCursor: null},
44+
nodes: [{viewerDidAuthor: true, body: .}]}}}}}' "${MOCK_COMMENTS_FILE:-/dev/null}"
4345
elif [[ "$1 $2" == "pr comment" ]]; then
4446
cat >/dev/null # consume the -F - body
4547
elif [[ "$1 $2" == "pr edit" ]]; then
4648
:
47-
elif [[ "$1 $2" == "pr list" ]]; then
49+
elif [[ "$1" == "api" ]]; then
4850
: # no sibling conflicts
4951
elif [[ "$1 $2" == "label create" ]]; then
5052
:
@@ -93,6 +95,7 @@ setup_repo() {
9395

9496
run_resume() {
9597
env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \
98+
GITHUB_REPOSITORY=tester/repo \
9699
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
97100
MOCK_LABELS="$MOCK_LABELS" \
98101
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \

update-pr-stack.sh

Lines changed: 36 additions & 9 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
@@ -41,15 +42,38 @@ format_state_marker() {
4142
}
4243

4344
# Echoes the most recent state-marker line found in our PR comments, or nothing.
45+
# Pages through the comments newest-first, so the marker is found no matter how
46+
# many comments the PR has accumulated since.
4447
read_state_marker() {
4548
local PR_NUMBER="$1"
46-
local BODIES
47-
if ! BODIES=$(gh pr view "$PR_NUMBER" --json comments \
48-
--jq '.comments[] | select(.viewerDidAuthor) | .body'); then
49-
echo "Error: could not read comments of PR #$PR_NUMBER" >&2
50-
exit 1
51-
fi
52-
{ grep -F "$STATE_MARKER_PREFIX" <<<"$BODIES" || true; } | tail -n1
49+
local QUERY='query($owner: String!, $repo: String!, $number: Int!, $before: String) {
50+
repository(owner: $owner, name: $repo) {
51+
pullRequest(number: $number) {
52+
comments(last: 50, before: $before) {
53+
pageInfo { hasPreviousPage startCursor }
54+
nodes { viewerDidAuthor body }
55+
}
56+
}
57+
}
58+
}'
59+
local PAGE MARKER CURSOR=""
60+
while :; do
61+
local ARGS=(-F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}"
62+
-F number="$PR_NUMBER" -f query="$QUERY")
63+
[[ -n "$CURSOR" ]] && ARGS+=(-f before="$CURSOR")
64+
if ! PAGE=$(gh api graphql "${ARGS[@]}"); then
65+
echo "Error: could not read comments of PR #$PR_NUMBER" >&2
66+
exit 1
67+
fi
68+
MARKER=$(jq -r '.data.repository.pullRequest.comments.nodes[] | select(.viewerDidAuthor) | .body' <<<"$PAGE" \
69+
| { grep -F "$STATE_MARKER_PREFIX" || true; } | tail -n1)
70+
if [[ -n "$MARKER" ]]; then
71+
printf '%s\n' "$MARKER"
72+
return 0
73+
fi
74+
[[ "$(jq -r '.data.repository.pullRequest.comments.pageInfo.hasPreviousPage' <<<"$PAGE")" == "true" ]] || return 0
75+
CURSOR=$(jq -r '.data.repository.pullRequest.comments.pageInfo.startCursor' <<<"$PAGE")
76+
done
5377
}
5478

5579
# Args: a marker line. Echoes "base target squash".
@@ -139,7 +163,8 @@ is_rebase_merge() {
139163

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

145170
# Args: head branch, base branch, PR number. git commands use the branch; gh
@@ -255,7 +280,8 @@ has_sibling_conflicts() {
255280

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

260286
for SIBLING in $CONFLICTED_SIBLINGS; do
261287
if [[ "$SIBLING" != "$EXCLUDE_BRANCH" ]]; then
@@ -281,6 +307,7 @@ continue_after_resolution() {
281307
check_env_var "PR_BRANCH"
282308
check_env_var "PR_NUMBER"
283309
check_env_var "PR_BASE"
310+
check_env_var "GITHUB_REPOSITORY"
284311

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

0 commit comments

Comments
 (0)