Stop pick from hiding long-lived PRs behind a creation-ordered window

`gh pr list` orders by creation date, so `--limit 30` cut the candidate set by
when PRs were opened, not when they merged. The `sort_by(.mergedAt)` that
followed only reordered whatever survived that cut. A PR opened before the
window but merged inside it — exactly the kind a release branch still needs —
never reached the already-on-branch check at all. #7649 and #7709 were both
missing from v4-0-2's list for this reason.

The window is now bounded by the branch point instead of a count: pull a wide
page and keep what merged after the merge-base's commit date, since anything
merged into the dev branch before the release branch left it is already there
by ancestry. v4-0-2 went from 11 candidates to 31.

Widening it surfaced a second gap. A change re-applied by hand carries neither
a PR number nor a cherry-pick trailer, so already_on_branch could not see it
and offered it again (#6939, applied as 33d7363c). It now also compares the PR
title against the branch's subjects, with any trailing "(#N)" stripped.
This commit is contained in:
Ryan Hughes
2026-08-28 15:13:58 -04:00
parent f84c89d8b3
commit a8c9e2982e
+36 -15
View File
@@ -499,13 +499,24 @@ cmd_pick() {
# on a patch branch. Detect equivalence the way it is actually recorded: # on a patch branch. Detect equivalence the way it is actually recorded:
# the branch's own commit messages since it left quattro name the PR # the branch's own commit messages since it left quattro name the PR
# ("backport of #N", squash titles "(#N)") or the source commit # ("backport of #N", squash titles "(#N)") or the source commit
# ("cherry picked from commit <sha>", which pick -x writes). # ("cherry picked from commit <sha>", which pick -x writes) — or, failing
local base_commit branch_log # both, repeat its title verbatim.
local base_commit base_date branch_log
base_commit=$(git -C "$WORK_CLONE" merge-base "origin/$branch" "origin/$DEV_BRANCH" 2>/dev/null) || base_commit="" base_commit=$(git -C "$WORK_CLONE" merge-base "origin/$branch" "origin/$DEV_BRANCH" 2>/dev/null) || base_commit=""
base_date=""
if [[ -n "$base_commit" ]]; then
base_date=$(TZ=UTC git -C "$WORK_CLONE" log -1 --format=%cd \
--date=format-local:'%Y-%m-%dT%H:%M:%SZ' "$base_commit" 2>/dev/null) || base_date=""
fi
branch_log=$(git -C "$WORK_CLONE" log --format='%s %b' "origin/$branch" ${base_commit:+--not "$base_commit"} 2>/dev/null) branch_log=$(git -C "$WORK_CLONE" log --format='%s %b' "origin/$branch" ${base_commit:+--not "$base_commit"} 2>/dev/null)
# Subjects alone, with any trailing "(#N)" stripped, so a change re-applied by
# hand — no PR number, no cherry-pick trailer — is still recognised by title.
local branch_subjects
branch_subjects=$(git -C "$WORK_CLONE" log --format='%s' "origin/$branch" ${base_commit:+--not "$base_commit"} 2>/dev/null |
sed -E 's/ \(#[0-9]+\)$//')
already_on_branch() { # already_on_branch <merge-sha> <pr-number-or-empty> already_on_branch() { # already_on_branch <merge-sha> <pr-number-or-empty> <title-or-empty>
local sha="$1" number="$2" local sha="$1" number="$2" title="$3"
if [[ -n "$sha" ]]; then if [[ -n "$sha" ]]; then
git -C "$WORK_CLONE" merge-base --is-ancestor "$sha" "origin/$branch" 2>/dev/null && return 0 git -C "$WORK_CLONE" merge-base --is-ancestor "$sha" "origin/$branch" 2>/dev/null && return 0
grep -q "cherry picked from commit $sha" <<<"$branch_log" && return 0 grep -q "cherry picked from commit $sha" <<<"$branch_log" && return 0
@@ -513,6 +524,9 @@ cmd_pick() {
if [[ -n "$number" ]]; then if [[ -n "$number" ]]; then
grep -qE "#$number([^0-9]|$)" <<<"$branch_log" && return 0 grep -qE "#$number([^0-9]|$)" <<<"$branch_log" && return 0
fi fi
if [[ -n "$title" ]]; then
grep -Fxq "$title" <<<"$branch_subjects" && return 0
fi
return 1 return 1
} }
@@ -527,13 +541,13 @@ cmd_pick() {
print_error "PR #$arg has no merge commit (not merged?)" print_error "PR #$arg has no merge commit (not merged?)"
exit 1 exit 1
fi fi
if already_on_branch "$sha" "$arg"; then if already_on_branch "$sha" "$arg" "$title"; then
print_info "PR #$arg is already on $branch — skipping" print_info "PR #$arg is already on $branch — skipping"
continue continue
fi fi
shas+=("$sha") labels+=("PR #$arg: $title") shas+=("$sha") labels+=("PR #$arg: $title")
else else
if already_on_branch "$arg" ""; then if already_on_branch "$arg" "" ""; then
print_info "commit $arg is already on $branch — skipping" print_info "commit $arg is already on $branch — skipping"
continue continue
fi fi
@@ -542,21 +556,28 @@ cmd_pick() {
done done
else else
# Interactive: merged dev-branch PRs not already on the release branch # Interactive: merged dev-branch PRs not already on the release branch
# (by ancestry, backport reference, or cherry-pick trailer), oldest first # (by ancestry, backport reference, cherry-pick trailer, or title), oldest
# so picks apply in merge order. # first so picks apply in merge order.
print_info "Loading merged $DEV_BRANCH PRs not yet on $branch..." print_info "Loading merged $DEV_BRANCH PRs not yet on $branch..."
local list # gh pr list orders by CREATION date, so a plain --limit window silently
# drops long-lived PRs — opened early, merged recently — which are exactly
# the ones a release branch still needs. Pull a wide window and bound it by
# the branch point instead: anything merged into $DEV_BRANCH before $branch
# left it is already here by ancestry.
local list jq_pick
jq_pick='sort_by(.mergedAt) | .[] | "\(.mergeCommit.oid)\t\(.number)\t\(.title)"'
[[ -n "$base_date" ]] && jq_pick="[.[] | select(.mergedAt >= \"$base_date\")] | $jq_pick"
list=$(gh pr list --repo "$UPSTREAM_REPO" --state merged --base "$DEV_BRANCH" \ list=$(gh pr list --repo "$UPSTREAM_REPO" --state merged --base "$DEV_BRANCH" \
--limit 30 --json number,title,mergeCommit,mergedAt \ --limit 300 --json number,title,mergeCommit,mergedAt --jq "$jq_pick")
--jq 'sort_by(.mergedAt) | .[] | "\(.mergeCommit.oid)\t\(.number)\t#\(.number) \(.title)"')
local -a cand_shas=() cand_labels=() local -a cand_shas=() cand_labels=()
while IFS=$'\t' read -r sha number label; do local sha number title
while IFS=$'\t' read -r sha number title; do
[[ -z "$sha" || "$sha" == "null" ]] && continue [[ -z "$sha" || "$sha" == "null" ]] && continue
already_on_branch "$sha" "$number" && continue already_on_branch "$sha" "$number" "$title" && continue
cand_shas+=("$sha") cand_labels+=("$label") cand_shas+=("$sha") cand_labels+=("#$number $title")
done <<<"$list" done <<<"$list"
if [[ ${#cand_shas[@]} -eq 0 ]]; then if [[ ${#cand_shas[@]} -eq 0 ]]; then
print_success "Nothing to pick — every recent merged PR is already on $branch" print_success "Nothing to pick — every $DEV_BRANCH PR merged since $branch opened is already on it"
exit 0 exit 0
fi fi
echo "" echo ""