Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,11 @@ stops the merge: PR 2084 was approved, enqueued, had that approval dismissed, an
call is a no-op. A failed dequeue logs a `::warning::` and posts one comment asking a maintainer to
remove the PR from the queue by hand. An unreadable live state logs a `::warning::` and takes no
action. The marker is posted at most once per head SHA.
- **A failed state read is retried, then surfaced.** The live read is tried `MQ_READ_ATTEMPTS` times
(default 3, `MQ_READ_RETRY_DELAY_SEC` apart, default 2). If every attempt fails the helper cannot tell
whether a queued PR lost its last approval: it logs a `::warning::`, writes the unknown state to the job
step summary so a maintainer can check the queue, makes no dequeue call and posts no PR comment (most PRs
that reach this path are not queued), and never fails the run (#2186).
- **Auto-merge after a dequeue.** A dequeue consumes the PR's auto-merge request. dev-lead's idempotent
`try_enable_auto_merge` re-enables it on its next pass; once an approval stands again, the PR
rejoins the queue. This is intended.
Expand Down
35 changes: 34 additions & 1 deletion scripts/lib/merge-queue-dequeue.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ _mq_read_state() {
' 2>/dev/null
}

# _mq_read_state_retry <owner> <name> <number>
# _mq_read_state with a bounded retry (#2186). The helper runs right after an
# approval was dismissed, and the window that matters is seconds long, so one
# transient GraphQL error must not end the check. Tunables (also for tests):
# MQ_READ_ATTEMPTS (default 3) and MQ_READ_RETRY_DELAY_SEC (default 2).
# Non-zero when every attempt failed.
_mq_read_state_retry() {
local owner="$1" name="$2" number="$3" st i
local attempts="${MQ_READ_ATTEMPTS:-3}" delay="${MQ_READ_RETRY_DELAY_SEC:-2}"
[[ "$attempts" =~ ^[1-9][0-9]*$ ]] || attempts=3
[[ "$delay" =~ ^[0-9]+$ ]] || delay=2
for ((i = 1; i <= attempts; i++)); do
if st=$(_mq_read_state "$owner" "$name" "$number"); then
printf '%s' "$st"
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo " merge-queue: live-state read failed (attempt $i of $attempts) — retrying" >&2
[ "$delay" -gt 0 ] && sleep "$delay"
fi
Comment thread
don-petry marked this conversation as resolved.
done
return 1
}

# _mq_post_notice <owner> <name> <number> <pr_url> <head> <body> [marker_prefix]
# Post <body> unless a comment carrying this head's marker already exists, so
# repeat runs at one head post one notice. If the comments cannot be listed,
Expand Down Expand Up @@ -98,9 +122,18 @@ mq_dequeue_if_unapproved() {
fi

local st status_st=0
st=$(_mq_read_state "$owner" "$name" "$number") || status_st=$?
st=$(_mq_read_state_retry "$owner" "$name" "$number") || status_st=$?
if [ "$status_st" -ne 0 ]; then
echo "::warning::merge-queue: could not read the merge-queue and review state of $pr_url — cannot tell whether a queued PR lost its last approval; check the queue by hand (#2174)"
# Surface it where a maintainer looks, not only in the run log (#2186). No PR
# comment on this path: most PRs that reach it are not in the queue at all.
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
printf '### Merge queue: state unknown after an approval was withdrawn\n\n'
printf 'The live merge-queue and review state of %s could not be read after every retry. ' "$pr_url"
printf 'If that pull request is in the merge queue it may merge without an approval: check the queue and remove it by hand if needed.\n\n'
} >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
fi
return 0
fi

Expand Down
44 changes: 44 additions & 0 deletions tests/test_merge_queue_dequeue.bats
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ setup() {
export PR_STATE_FILE="$TEST_DIR/pr_state"; echo OPEN > "$PR_STATE_FILE"
export CLOCK_FILE="$TEST_DIR/clock"; echo 10 > "$CLOCK_FILE"
export CALLS="$TEST_DIR/gh_calls.log"; : > "$CALLS"
# #2186: the live-state read retries; no real sleeps in tests.
export MQ_READ_RETRY_DELAY_SEC=0
export READ_FAILS_FILE="$TEST_DIR/read_fails"; echo 0 > "$READ_FAILS_FILE"

cat > "$TEST_DIR/bin/gh" <<'GHEOF'
#!/bin/bash
# Stateful gh mock for #2174. Env knobs:
# GRAPHQL_READ_RC — non-zero ⇒ the live-state GraphQL read fails
# GRAPHQL_READ_FAIL_TIMES — N ⇒ only the first N live-state reads fail (#2186)
# DEQUEUE_RC — non-zero ⇒ dequeuePullRequest fails (state unchanged)
# LEAVE_QUEUE_ON_DEQUEUE — 1 ⇒ the PR leaves the queue just before the mutation,
# which then fails (the check/call race)
Expand Down Expand Up @@ -106,6 +110,12 @@ if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then
exit 0
fi
[ "${GRAPHQL_READ_RC:-0}" = "0" ] || { echo 'HTTP 502' >&2; exit "$GRAPHQL_READ_RC"; }
if [ "${GRAPHQL_READ_FAIL_TIMES:-0}" != "0" ]; then
n=$(cat "$READ_FAILS_FILE")
if [ "$n" -lt "$GRAPHQL_READ_FAIL_TIMES" ]; then
echo $((n + 1)) > "$READ_FAILS_FILE"; echo 'HTTP 502' >&2; exit 1
fi
fi
# Live state: latest review per author (GitHub's latestOpinionatedReviews).
jq -n --slurpfile r "$REVIEWS_FILE" --arg st "$(cat "$PR_STATE_FILE")" \
--arg q "$(cat "$QUEUED_FILE")" --arg h "$PR_HEAD_SHA" '
Expand Down Expand Up @@ -331,6 +341,40 @@ fix_request_verdict() {
[ "$(jq length "$COMMENTS_FILE")" -eq 0 ]
}

@test "live-state read fails once then succeeds → the queued, unapproved PR is still dequeued (#2186)" {
export GRAPHQL_READ_FAIL_TIMES=1
run_lib
[ "$status" -eq 0 ]
[[ "$output" == *"attempt 1 of 3"* ]]
[ "$(dequeue_calls)" -eq 1 ]
[ "$(cat "$QUEUED_FILE")" = "false" ]
[ "$(marker_count)" -eq 1 ]
}

@test "every live-state read fails → bounded retries, no dequeue, no comment, step-summary entry, exit 0 (#2186)" {
export GRAPHQL_READ_RC=1 MQ_READ_ATTEMPTS=4
export GITHUB_STEP_SUMMARY="$TEST_DIR/summary.md"; : > "$GITHUB_STEP_SUMMARY"
run_lib
[ "$status" -eq 0 ]
[[ "$output" == *"::warning::"* ]]
# Exactly MQ_READ_ATTEMPTS reads, then it stops.
[ "$(grep -c 'api graphql' "$CALLS")" -eq 4 ]
[ "$(dequeue_calls)" -eq 0 ]
[ "$(jq length "$COMMENTS_FILE")" -eq 0 ]
grep -q 'state unknown after an approval was withdrawn' "$GITHUB_STEP_SUMMARY"
grep -qF "$PR_URL" "$GITHUB_STEP_SUMMARY"
}

@test "a malformed retry setting falls back to the default instead of looping or aborting (#2186)" {
export GRAPHQL_READ_RC=1 MQ_READ_ATTEMPTS=abc MQ_READ_RETRY_DELAY_SEC=-1
# -1 is not a valid delay, so the default (2s) would apply; keep the test fast
# by proving only the attempt count here with a valid zero delay.
export MQ_READ_RETRY_DELAY_SEC=0
run_lib
[ "$status" -eq 0 ]
[ "$(grep -c 'api graphql' "$CALLS")" -eq 3 ]
}

@test "under set -e the helper never aborts its caller" {
export DEQUEUE_RC=1 COMMENT_LIST_RC=1
run bash -c 'set -euo pipefail; source "$LIB"; mq_dequeue_if_unapproved "$1"; echo CONTINUED' _ "$PR_URL"
Expand Down
Loading