Skip to content

Commit 066613a

Browse files
authored
Stop treating gh API failures as negative answers (#53)
Two spots swallowed a failed PR listing (gh api .../pulls) and acted on the empty answer (each verified destructive against the pre-fix script with the new test scenarios): - has_sibling_conflicts: a failure read as "no siblings", so the resume deleted the kept parent branch while another conflicted PR may still need it for its resolution. - The list_child_prs callers in main(): the process substitutions consuming it drop its exit status, so a failure read as "no children" and the run deleted the merged branch under the children it never saw, without updating them. has_sibling_conflicts now dies on the failed listing; the list_child_prs callers capture its output and die on failure. https://claude.ai/code/session_01STkeSJ7cLrmrNn4aTDYkwH
1 parent 9b07d07 commit 066613a

4 files changed

Lines changed: 70 additions & 7 deletions

File tree

tests/mock_gh.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
# Only direct children are queried now (no recursive updates of indirect children).
55

66
if [[ "$1" == "api" && "$2" == repos/*"/pulls?base="* ]]; then
7+
if [[ "${MOCK_PR_LIST_FAIL:-}" == 1 ]]; then
8+
echo "mock gh: pr list API down" >&2
9+
exit 1
10+
fi
711
# Open PRs based on a branch (already --jq filtered to "<number> <head>").
812
base="${2#*pulls\?base=}"
913
base="${base%%&*}"

tests/test_conflict_resolution_resume.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); }
2323
# MOCK_LABELS newline-separated labels returned by `pr view --json labels`
2424
# MOCK_COMMENTS_FILE file served as the body of our own PR comments
2525
# MOCK_LABELS_FAIL set to 1 to make `pr view --json labels` fail
26+
# MOCK_PR_LIST_FAIL set to 1 to make `pr list` fail
2627
# The PR's base branch is not mocked: the script must take it from PR_BASE
2728
# (event payload), so a baseRefName query is an unhandled call and fails.
2829
make_mock_gh() {
@@ -48,6 +49,7 @@ elif [[ "$1 $2" == "pr comment" ]]; then
4849
elif [[ "$1 $2" == "pr edit" ]]; then
4950
:
5051
elif [[ "$1" == "api" ]]; then
52+
[[ "${MOCK_PR_LIST_FAIL:-}" == 1 ]] && { echo "mock gh: pr list API down" >&2; exit 1; }
5153
: # no sibling conflicts
5254
elif [[ "$1 $2" == "label create" ]]; then
5355
:
@@ -100,6 +102,7 @@ run_resume() {
100102
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
101103
MOCK_LABELS="$MOCK_LABELS" MOCK_LABELS_FAIL="${MOCK_LABELS_FAIL:-}" \
102104
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \
105+
MOCK_PR_LIST_FAIL="${MOCK_PR_LIST_FAIL:-}" \
103106
bash "$ROOT_DIR/update-pr-stack.sh" >"$WORK/out.log" 2>&1 || echo "EXIT=$?" >>"$WORK/out.log"
104107
}
105108

@@ -256,5 +259,26 @@ grep -q "remove-label" "$CALLS" && fail "H: label must NOT be touched on an API
256259
[[ "$(git -C "$ORIGIN" rev-parse child)" == "$CHILD_BEFORE" ]] || fail "H: child was pushed"
257260
ok "H: labels API failure fails the run instead of skipping the resume"
258261

262+
# ---------------------------------------------------------------------------
263+
echo "### Scenario I: listing sibling conflicts fails -> keep the old base branch"
264+
setup_repo
265+
# Same successful-resume setup as scenario C, but the sibling listing that
266+
# decides whether the old base branch can be deleted fails. Answering "no
267+
# siblings" there would delete a branch another conflicted PR still needs.
268+
git -C "$WORK" merge -q --no-edit main
269+
git -C "$WORK" push -q origin child
270+
MOCK_LABELS="autorestack-needs-conflict-resolution"
271+
MOCK_LABELS_FAIL=""
272+
PR_BASE="parent"
273+
MOCK_PR_LIST_FAIL=1
274+
MOCK_COMMENTS_FILE="$WORK/comments.txt"
275+
{ echo "### conflict"; echo; marker parent main "$SQUASH"; } > "$MOCK_COMMENTS_FILE"
276+
run_resume
277+
278+
grep -q "EXIT=" "$WORK/out.log" || fail "I: run should have failed"
279+
git -C "$ORIGIN" rev-parse --verify -q parent >/dev/null || fail "I: old base branch was deleted on an API failure"
280+
grep -q -- "push origin :parent" "$CALLS" && fail "I: deletion must not be attempted"
281+
ok "I: sibling-listing API failure keeps the old base branch"
282+
259283
echo
260284
echo "All conflict-resume tests passed 🎉 ($PASS scenarios)"

tests/test_update_pr_stack.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,31 @@ else
212212
exit 1
213213
fi
214214

215+
# A failed children listing must fail the run before any mutation: silently
216+
# treating it as "no children" would delete the merged branch under the
217+
# children it never saw.
218+
echo -e "\nRunning update script with a failing pr list..."
219+
FAIL_LOG="$TEST_REPO/update_fail_run.log"
220+
if log_cmd env \
221+
SQUASH_COMMIT=$SQUASH_COMMIT \
222+
MERGED_BRANCH=feature1 \
223+
PR_NUMBER=1 \
224+
TARGET_BRANCH=main \
225+
MOCK_PR_LIST_FAIL=1 \
226+
GH="$SCRIPT_DIR/mock_gh.sh" \
227+
GIT="$SCRIPT_DIR/mock_git.sh" \
228+
$SCRIPT_DIR/../update-pr-stack.sh > "$FAIL_LOG" 2>&1; then
229+
echo "❌ run must fail when the children cannot be listed"
230+
cat "$FAIL_LOG"
231+
exit 1
232+
fi
233+
if grep -q "git push origin :feature1" "$FAIL_LOG"; then
234+
echo "❌ merged branch must not be deleted when the children cannot be listed"
235+
cat "$FAIL_LOG"
236+
exit 1
237+
fi
238+
echo "✅ Failing pr list fails the run without deleting the merged branch"
239+
215240
echo -e "\nAll tests passed! 🎉"
216241

217242
# Clean up

update-pr-stack.sh

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ format_state_marker() {
4646
}
4747

4848
# Echoes the most recent state-marker line found in our PR comments, or nothing.
49+
# Dies when the comments cannot be read at all: an API failure must not pass
50+
# for "no marker", which the caller treats as a reason to give up the resume
51+
# and remove the conflict label for good.
4952
read_state_marker() {
5053
local PR_NUMBER="$1"
5154
local BODIES
@@ -149,9 +152,10 @@ is_rebase_merge() {
149152
}
150153

151154
# Echoes "<number> <head branch>" for each open PR based on the merged branch.
152-
# try, not run: callers consume this through a process substitution, which
153-
# swallows the exit status either way; a die in here would only leave that
154-
# subshell. Making the callers notice the failure is a separate concern.
155+
# try, not run: callers run this in a command substitution, where a die would
156+
# only leave the subshell, so they capture the output and die themselves. An
157+
# unhandled failure here must not pass for "no children": the caller would
158+
# then delete the merged branch under the children it never saw.
155159
list_child_prs() {
156160
try gh api "repos/{owner}/{repo}/pulls?base=$MERGED_BRANCH&state=open&per_page=100" \
157161
--paginate --jq '.[] | "\(.number) \(.head.ref)"'
@@ -252,14 +256,17 @@ pr_has_conflict_label() {
252256

253257
# Check if any other PRs with conflict label still depend on a given base branch
254258
# Returns 0 (true) if siblings exist, 1 (false) if no siblings
259+
# Dies when the PRs cannot be listed: answering "no siblings" on an API failure
260+
# makes the caller delete a branch a sibling may still need for its resolution.
255261
has_sibling_conflicts() {
256262
local BASE_BRANCH="$1"
257263
local EXCLUDE_BRANCH="$2"
258264

259265
# Find all open PRs with the conflict label that are based on BASE_BRANCH
260266
local CONFLICTED_SIBLINGS
261267
CONFLICTED_SIBLINGS=$(gh api "repos/{owner}/{repo}/pulls?base=$BASE_BRANCH&state=open&per_page=100" \
262-
--paginate --jq ".[] | select(any(.labels[]; .name == \"$CONFLICT_LABEL\")) | .head.ref" 2>/dev/null || echo "")
268+
--paginate --jq ".[] | select(any(.labels[]; .name == \"$CONFLICT_LABEL\")) | .head.ref") \
269+
|| die "could not list conflicted PRs based on $BASE_BRANCH"
263270

264271
for SIBLING in $CONFLICTED_SIBLINGS; do
265272
if [[ "$SIBLING" != "$EXCLUDE_BRANCH" ]]; then
@@ -395,10 +402,11 @@ main() {
395402
# children and delete the merged branch.
396403
if git rev-parse --verify --quiet SQUASH_COMMIT^2 >/dev/null; then
397404
echo "✓ '$MERGED_BRANCH' was merged with a merge commit, not squashed; retargeting children without touching their heads"
405+
CHILDREN=$(list_child_prs) || die "could not list the PRs based on $MERGED_BRANCH"
398406
while read -r NUMBER BRANCH; do
399407
[[ -n "$BRANCH" ]] || continue
400408
run gh pr edit "$NUMBER" --base "$TARGET_BRANCH"
401-
done < <(list_child_prs)
409+
done <<<"$CHILDREN"
402410
# Deleting a PR's base branch closes the PR, so the retargets come first.
403411
run git push origin ":$MERGED_BRANCH"
404412
return 0
@@ -410,21 +418,23 @@ main() {
410418
# the intermediate copies. Tell the children and leave everything alone.
411419
if is_rebase_merge "$PR_NUMBER"; then
412420
echo "⚠️ '$MERGED_BRANCH' looks rebase-merged; rebase merges are not supported, leaving the stack alone"
421+
CHILDREN=$(list_child_prs) || die "could not list the PRs based on $MERGED_BRANCH"
413422
while read -r NUMBER BRANCH; do
414423
[[ -n "$BRANCH" ]] || continue
415424
run gh pr comment "$NUMBER" --body "ℹ️ The base branch \`$MERGED_BRANCH\` of this PR was merged with \"Rebase and merge\", which autorestack does not support. Update this PR manually. \`$MERGED_BRANCH\` was kept so this PR stays open."
416-
done < <(list_child_prs)
425+
done <<<"$CHILDREN"
417426
return 0
418427
fi
419428

420429
# Find all PRs directly targeting the merged PR's head
430+
CHILDREN=$(list_child_prs) || die "could not list the PRs based on $MERGED_BRANCH"
421431
INITIAL_NUMBERS=()
422432
INITIAL_TARGETS=()
423433
while read -r NUMBER BRANCH; do
424434
[[ -n "$BRANCH" ]] || continue
425435
INITIAL_NUMBERS+=("$NUMBER")
426436
INITIAL_TARGETS+=("$BRANCH")
427-
done < <(list_child_prs)
437+
done <<<"$CHILDREN"
428438

429439
# Track successfully updated vs conflicted branches separately
430440
UPDATED_TARGETS=()

0 commit comments

Comments
 (0)