From 75d0c770220b9b54a67de89f17c7b7a228f2440c Mon Sep 17 00:00:00 2001 From: strtgbb <146047128+strtgbb@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:43:04 -0400 Subject: [PATCH 1/5] still trying to fix failed picks --- .github/workflows/flaky_fix_sync.yml | 12 +++++ tests/ci/backport_flaky_fixes.py | 75 ++++++++++++---------------- 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/.github/workflows/flaky_fix_sync.yml b/.github/workflows/flaky_fix_sync.yml index 0e28cc05962d..f9bf8ef1cacf 100644 --- a/.github/workflows/flaky_fix_sync.yml +++ b/.github/workflows/flaky_fix_sync.yml @@ -74,6 +74,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 1 + filter: blob:none - name: Set up Python uses: actions/setup-python@v4 @@ -85,6 +86,17 @@ jobs: with: name: flaky-fix-check + - name: Configure git identity + run: | + git config user.name altinity-robot + git config user.email altinity-robot@users.noreply.github.com + + - name: Add upstream promisor remote + run: | + git remote add upstream https://github.com/ClickHouse/ClickHouse.git + git config remote.upstream.promisor true + git config remote.upstream.partialclonefilter blob:none + - name: Cherry-pick and open PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/ci/backport_flaky_fixes.py b/tests/ci/backport_flaky_fixes.py index 94a438d3b44e..1724bedb1492 100755 --- a/tests/ci/backport_flaky_fixes.py +++ b/tests/ci/backport_flaky_fixes.py @@ -6,18 +6,13 @@ import argparse import json -import os import re import subprocess import sys -import tempfile -import urllib.request -import urllib.error from datetime import datetime, timezone UPSTREAM_COMMIT_URL = "https://github.com/{repo}/commit/{sha}" -UPSTREAM_PATCH_URL = "https://github.com/{repo}/commit/{sha}.patch" # Branches like antalya-26.3 or stable-25.8 → family label + version label. _BRANCH_LABEL_RE = re.compile(r'^([a-z]+)-(\d+\.\d+)$') @@ -65,40 +60,30 @@ def git_out(*args) -> str: return run_git(*args).stdout.strip() -def download_patch(upstream_repo: str, sha: str) -> bytes: - """Download the commit patch from GitHub over HTTPS. Much faster than git fetch.""" - url = UPSTREAM_PATCH_URL.format(repo=upstream_repo, sha=sha) - token = os.environ.get("GITHUB_TOKEN") - headers = {"User-Agent": "backport_flaky_fixes"} - if token: - headers["Authorization"] = f"Bearer {token}" - req = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(req) as resp: - return resp.read() - except urllib.error.HTTPError as e: - print(f"Failed to download patch for {sha[:12]}: {e}", file=sys.stderr) - return b"" - - -def apply_commit(upstream_repo: str, sha: str) -> bool: - """Download the patch from GitHub and apply it with git am --3way. - Returns True on success, False on conflict or failure.""" - patch = download_patch(upstream_repo, sha) - if not patch: - return False - with tempfile.NamedTemporaryFile(suffix=".patch", delete=False) as f: - f.write(patch) - patch_file = f.name - try: - result = run_git("am", "--3way", patch_file, check=False) - if result.returncode == 0: - return True - print(f" git am failed for {sha[:12]}, aborting.", file=sys.stderr) - run_git("am", "--abort", check=False) - return False - finally: - os.unlink(patch_file) +def prefetch_upstream_objects(shas: list) -> None: + """Blobless-fetch the target commits and their parents from the 'upstream' + promisor remote (configured by the workflow) so `git cherry-pick` has the + commit and its merge base locally and can resolve blobs on demand. + --depth=2 pulls each commit plus its parent without the full ancestry (a plain + fetch into a shallow clone drags the entire history), in a single cheap + negotiation. No-op when no 'upstream' remote exists (e.g. local runs on a full + clone).""" + if not shas or "upstream" not in git_out("remote").split(): + print("No 'upstream' remote; skipping prefetch (assuming objects are local).", file=sys.stderr) + return + run_git("fetch", "--depth=2", "--no-tags", "upstream", *shas, check=False) + + +def cherry_pick(sha: str) -> bool: + """Cherry-pick a commit. Blobs are lazily fetched from the upstream promisor + remote by full sha via the parent trees pulled by prefetch_upstream_objects. + Returns True on success, False on conflict or failure (which is aborted).""" + result = run_git("cherry-pick", "-x", sha, check=False) + if result.returncode == 0: + return True + print(f" cherry-pick of {sha[:12]} failed, aborting:\n{result.stdout}{result.stderr}", file=sys.stderr) + run_git("cherry-pick", "--abort", check=False) + return False def build_pr_body( @@ -121,7 +106,7 @@ def build_pr_body( lines.append("") if conflicted: - lines.append("### Skipped (conflict or download failure — manual backport needed)") + lines.append("### Skipped (cherry-pick conflict — manual backport needed)") lines.append("") for sha, subject, date in conflicted: url = UPSTREAM_COMMIT_URL.format(repo=upstream_repo, sha=sha) @@ -201,6 +186,8 @@ def main() -> None: # Sort oldest-first so cherry-picks apply in chronological order. missing.sort(key=lambda c: c["date"]) + prefetch_upstream_objects([c["sha"] for c in missing]) + date_tag = datetime.now(timezone.utc).strftime("%Y-%m-%d") backport_branch = f"flaky-fix-backport/{base_branch}/{date_tag}" @@ -218,16 +205,16 @@ def main() -> None: sha = commit["sha"] subject = commit["subject"] date = commit["date"] - print(f"Applying {sha[:12]}: {subject}", file=sys.stderr) - if apply_commit(upstream_repo, sha): + print(f"Cherry-picking {sha[:12]}: {subject}", file=sys.stderr) + if cherry_pick(sha): applied.append((sha, subject, date)) print(f" Applied {sha[:12]}", file=sys.stderr) else: conflicted.append((sha, subject, date)) - print(f" Skipped {sha[:12]} (conflict or download failed)", file=sys.stderr) + print(f" Skipped {sha[:12]} (conflict)", file=sys.stderr) if not applied: - print("No commits could be applied (all conflicted or failed to fetch). Cleaning up.", file=sys.stderr) + print("No commits could be applied (all conflicted). Cleaning up.", file=sys.stderr) run_git("checkout", base_branch) run_git("branch", "-D", backport_branch) sys.exit(0) From 273ef568f69abf143b040e61daff6f4d888cfecd Mon Sep 17 00:00:00 2001 From: strtgbb <146047128+strtgbb@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:58:17 -0400 Subject: [PATCH 2/5] reuse existing PR if possible in flaky backports --- tests/ci/backport_flaky_fixes.py | 161 +++++++++++++++++++++++++------ 1 file changed, 131 insertions(+), 30 deletions(-) diff --git a/tests/ci/backport_flaky_fixes.py b/tests/ci/backport_flaky_fixes.py index 1724bedb1492..9a18c57cf454 100755 --- a/tests/ci/backport_flaky_fixes.py +++ b/tests/ci/backport_flaky_fixes.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ -Read the JSON output from check_for_flaky_fix_backports.py, cherry-pick each -missing commit onto a new branch, push it, and open a single PR. +Read the JSON output from check_for_flaky_fixes.py and cherry-pick each missing +commit onto a backport branch. Opens a new PR, or amends the open one from a +previous run if it already exists for this base branch. """ import argparse @@ -86,6 +87,17 @@ def cherry_pick(sha: str) -> bool: return False +def _commit_line(upstream_repo: str, sha, subject: str, date) -> str: + """Render one bullet. sha/date may be None for commits carried over from a + reused PR, where only the subject is known.""" + link = "" + if sha: + url = UPSTREAM_COMMIT_URL.format(repo=upstream_repo, sha=sha) + link = f"[`{sha[:12]}`]({url}) " + suffix = f" _(committed {date})_" if date else "" + return f"- {link}{subject}{suffix}" + + def build_pr_body( upstream_repo: str, applied: list, @@ -101,21 +113,81 @@ def build_pr_body( lines.append("### Applied") lines.append("") for sha, subject, date in applied: - url = UPSTREAM_COMMIT_URL.format(repo=upstream_repo, sha=sha) - lines.append(f"- [`{sha[:12]}`]({url}) {subject} _(committed {date})_") + lines.append(_commit_line(upstream_repo, sha, subject, date)) lines.append("") if conflicted: lines.append("### Skipped (cherry-pick conflict — manual backport needed)") lines.append("") for sha, subject, date in conflicted: - url = UPSTREAM_COMMIT_URL.format(repo=upstream_repo, sha=sha) - lines.append(f"- [`{sha[:12]}`]({url}) {subject} _(committed {date})_") + lines.append(_commit_line(upstream_repo, sha, subject, date)) lines.append("") return "\n".join(lines) +def find_open_backport_pr(repo: str, base_branch: str): + """Return (number, head_branch) of an open flaky-fix backport PR for this base + branch, or (None, None). Lets a weekly run amend last week's PR instead of + opening a fresh one each time.""" + prefix = f"flaky-fix-backport/{base_branch}/" + result = subprocess.run( + ["gh", "pr", "list", "--repo", repo, "--state", "open", "--base", base_branch, + "--json", "number,headRefName", "--limit", "100"], + text=True, capture_output=True, + ) + if result.returncode != 0: + print(f"Warning: gh pr list failed, will open a new PR:\n{result.stderr}", file=sys.stderr) + return None, None + for pr in json.loads(result.stdout): + if pr["headRefName"].startswith(prefix): + return pr["number"], pr["headRefName"] + return None, None + + +def pr_backport_commits(repo: str, number: int) -> list: + """Return [(upstream_sha_or_None, subject)] for commits already on the PR, + oldest-first, by reading the `(cherry picked from commit )` trailer that + `cherry-pick -x` records.""" + result = subprocess.run( + ["gh", "pr", "view", str(number), "--repo", repo, "--json", "commits"], + text=True, capture_output=True, + ) + if result.returncode != 0: + print(f"Warning: could not read commits of PR #{number}:\n{result.stderr}", file=sys.stderr) + return [] + out = [] + for c in json.loads(result.stdout).get("commits", []): + body = c.get("messageBody", "") or "" + m = re.search(r"cherry picked from commit ([0-9a-f]{7,40})", body) + out.append((m.group(1) if m else None, c.get("messageHeadline", ""))) + return out + + +def update_pr(repo: str, number: int, title: str, body: str, labels: list, dry_run: bool) -> None: + if dry_run: + print(f"DRY RUN: would update PR #{number} in {repo}:", file=sys.stderr) + print(f" title: {title}", file=sys.stderr) + print(f" labels: {', '.join(labels)}", file=sys.stderr) + return + + labels = existing_labels(repo, labels) + + cmd = [ + "gh", "pr", "edit", str(number), + "--repo", repo, + "--title", title, + "--body", body, + ] + for label in labels: + cmd += ["--add-label", label] + + result = subprocess.run(cmd, text=True, capture_output=False) + if result.returncode != 0: + print("gh pr edit failed", file=sys.stderr) + sys.exit(1) + + def create_pr(repo: str, branch: str, base: str, title: str, body: str, labels: list, dry_run: bool) -> None: if dry_run: print(f"DRY RUN: would open PR in {repo}:", file=sys.stderr) @@ -153,7 +225,7 @@ def parse_args() -> argparse.Namespace: "--json-file", required=True, metavar="PATH", - help="JSON output file produced by check_flaky_fix_backports.py", + help="JSON output file produced by check_for_flaky_fixes.py", ) parser.add_argument( "--repo", @@ -186,17 +258,36 @@ def main() -> None: # Sort oldest-first so cherry-picks apply in chronological order. missing.sort(key=lambda c: c["date"]) + # Reuse an open PR from a previous run if one exists, so we amend a single + # rolling PR per base branch instead of opening a new one every week. + reuse_number, reuse_branch = find_open_backport_pr(args.repo, base_branch) + + prior_applied = [] + if reuse_branch: + print(f"Reusing open PR #{reuse_number} ({reuse_branch}).", file=sys.stderr) + prior_commits = pr_backport_commits(args.repo, reuse_number) + prior_shas = {sha for sha, _ in prior_commits if sha} + prior_applied = [(sha, subject, None) for sha, subject in prior_commits] + missing = [c for c in missing if c["sha"] not in prior_shas] + if not missing: + print(f"PR #{reuse_number} already contains all missing commits. Nothing to do.", file=sys.stderr) + return + prefetch_upstream_objects([c["sha"] for c in missing]) date_tag = datetime.now(timezone.utc).strftime("%Y-%m-%d") - backport_branch = f"flaky-fix-backport/{base_branch}/{date_tag}" - existing = run_git("branch", "--list", backport_branch).stdout.strip() - if existing: - print(f"Branch {backport_branch} already exists. Delete it first.", file=sys.stderr) - sys.exit(1) - - run_git("checkout", "-b", backport_branch) + if reuse_branch: + backport_branch = reuse_branch + run_git("fetch", "--depth=1", "--no-tags", "origin", reuse_branch) + run_git("checkout", "-B", backport_branch, "FETCH_HEAD") + else: + backport_branch = f"flaky-fix-backport/{base_branch}/{date_tag}" + existing = run_git("branch", "--list", backport_branch).stdout.strip() + if existing: + print(f"Branch {backport_branch} already exists. Delete it first.", file=sys.stderr) + sys.exit(1) + run_git("checkout", "-b", backport_branch) applied = [] conflicted = [] @@ -213,39 +304,49 @@ def main() -> None: conflicted.append((sha, subject, date)) print(f" Skipped {sha[:12]} (conflict)", file=sys.stderr) - if not applied: + if not reuse_branch and not applied: print("No commits could be applied (all conflicted). Cleaning up.", file=sys.stderr) run_git("checkout", base_branch) run_git("branch", "-D", backport_branch) sys.exit(0) + all_applied = prior_applied + applied pr_title = f"{base_branch.title()} - Backport flaky-fix commits from upstream ({date_tag})" - pr_body = build_pr_body(upstream_repo, applied, conflicted) + pr_body = build_pr_body(upstream_repo, all_applied, conflicted) pr_labels = labels_for_branch(base_branch) if args.dry_run: + action = f"amend PR #{reuse_number}" if reuse_branch else f"open PR against {base_branch}" print( - f"DRY RUN: would push {backport_branch} and open PR against {base_branch}.", + f"DRY RUN: would push {backport_branch} and {action}.", file=sys.stderr, ) - print(f" Applied: {len(applied)} Conflicted/failed: {len(conflicted)}", file=sys.stderr) + print(f" Newly applied: {len(applied)} Carried over: {len(prior_applied)} Conflicted: {len(conflicted)}", file=sys.stderr) print(f"\n--- PR title ---\n{pr_title}\n--- PR body ---\n{pr_body}---") - create_pr(repo=args.repo, branch=backport_branch, base=base_branch, title=pr_title, body=pr_body, labels=pr_labels, dry_run=True) + if reuse_branch: + update_pr(repo=args.repo, number=reuse_number, title=pr_title, body=pr_body, labels=pr_labels, dry_run=True) + else: + create_pr(repo=args.repo, branch=backport_branch, base=base_branch, title=pr_title, body=pr_body, labels=pr_labels, dry_run=True) run_git("checkout", base_branch) run_git("branch", "-D", backport_branch) return - run_git("push", "origin", backport_branch, capture=False) - - create_pr( - repo=args.repo, - branch=backport_branch, - base=base_branch, - title=pr_title, - body=pr_body, - labels=pr_labels, - dry_run=False, - ) + # Only push when we added commits; body-only refreshes still update the PR. + if applied: + run_git("push", "origin", backport_branch, capture=False) + + if reuse_branch: + update_pr(repo=args.repo, number=reuse_number, title=pr_title, body=pr_body, labels=pr_labels, dry_run=False) + else: + create_pr( + repo=args.repo, + branch=backport_branch, + base=base_branch, + title=pr_title, + body=pr_body, + labels=pr_labels, + dry_run=False, + ) if __name__ == "__main__": From 4a845fc47c423d72a16dd8489ad1cac70454c1c0 Mon Sep 17 00:00:00 2001 From: strtgbb <146047128+strtgbb@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:45:26 -0400 Subject: [PATCH 3/5] update search pattern to 'fix flaky' --- .github/workflows/flaky_fix_sync.yml | 4 ++-- tests/ci/check_for_flaky_fixes.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/flaky_fix_sync.yml b/.github/workflows/flaky_fix_sync.yml index f9bf8ef1cacf..a54d2ac70b9f 100644 --- a/.github/workflows/flaky_fix_sync.yml +++ b/.github/workflows/flaky_fix_sync.yml @@ -12,7 +12,7 @@ on: description: 'Commit subject pattern to match (case-insensitive)' required: false type: string - default: 'fix flaky test' + default: 'fix flaky' upstream_ref: description: 'Upstream branch/ref to scan' required: false @@ -48,7 +48,7 @@ jobs: run: | python3 tests/ci/check_for_flaky_fixes.py \ --since-days "${{ inputs.since_days || '7' }}" \ - --pattern "${{ inputs.pattern || 'fix flaky test' }}" \ + --pattern "${{ inputs.pattern || 'fix flaky' }}" \ --upstream-ref "${{ inputs.upstream_ref || 'master' }}" \ --md-output flaky_fix_report.md \ --json-output flaky_fix.json diff --git a/tests/ci/check_for_flaky_fixes.py b/tests/ci/check_for_flaky_fixes.py index f3f9da129c19..b40b08c6eb54 100755 --- a/tests/ci/check_for_flaky_fixes.py +++ b/tests/ci/check_for_flaky_fixes.py @@ -144,7 +144,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--pattern", - default="fix flaky test", + default="fix flaky", help="case-insensitive substring to match in commit subjects", ) parser.add_argument( From 9f3c50ff7923d095ce4cea41e8f5a0e5cfc955b7 Mon Sep 17 00:00:00 2001 From: strtgbb <146047128+strtgbb@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:22:24 -0400 Subject: [PATCH 4/5] add active branches to flaky_fix_sync --- .github/workflows/flaky_fix_sync.yml | 57 ++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/.github/workflows/flaky_fix_sync.yml b/.github/workflows/flaky_fix_sync.yml index a54d2ac70b9f..4065e89cba2e 100644 --- a/.github/workflows/flaky_fix_sync.yml +++ b/.github/workflows/flaky_fix_sync.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: since_days: - description: 'How many days back to scan upstream master (default: 7)' + description: 'How many days back to scan upstream (default: 7)' required: false type: string default: '7' @@ -25,11 +25,29 @@ on: default: false schedule: - - cron: '0 12 * * 1' # Every Monday at 08:00 EDT (12:00 UTC) + - cron: '0 10 * * 1' # Every Monday at 06:00 EDT (10:00 UTC) + jobs: + setup: + runs-on: ubuntu-latest + outputs: + branches: ${{ steps.set_branches.outputs.branches }} + steps: + - id: set_branches + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "branches=[\"${{ github.ref_name }}\"]" >> $GITHUB_OUTPUT + else + echo "branches=[\"antalya-26.3\", \"stable-26.3\", \"antalya-26.6\"]" >> $GITHUB_OUTPUT + fi + check: + needs: setup runs-on: [self-hosted, altinity-on-demand, altinity-style-checker] + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -37,6 +55,9 @@ jobs: fetch-depth: 0 filter: blob:none + - name: Checkout target branch + run: git checkout ${{ matrix.branch }} + - name: Set up Python uses: actions/setup-python@v4 with: @@ -47,27 +68,30 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python3 tests/ci/check_for_flaky_fixes.py \ - --since-days "${{ inputs.since_days || '7' }}" \ - --pattern "${{ inputs.pattern || 'fix flaky' }}" \ - --upstream-ref "${{ inputs.upstream_ref || 'master' }}" \ - --md-output flaky_fix_report.md \ - --json-output flaky_fix.json + --since-days "${{ github.event.inputs.since_days || '7' }}" \ + --pattern "${{ github.event.inputs.pattern || 'fix flaky' }}" \ + --upstream-ref "${{ github.event.inputs.upstream_ref || 'master' }}" \ + --md-output flaky_fix_report_${{ matrix.branch }}.md \ + --json-output flaky_fix_${{ matrix.branch }}.json - name: Write summary if: always() - run: cat flaky_fix_report.md >> "$GITHUB_STEP_SUMMARY" + run: cat flaky_fix_report_${{ matrix.branch }}.md >> "$GITHUB_STEP_SUMMARY" - name: Upload check results uses: actions/upload-artifact@v4 with: - name: flaky-fix-check + name: flaky-fix-check-${{ matrix.branch }} path: | - flaky_fix_report.md - flaky_fix.json + flaky_fix_report_${{ matrix.branch }}.md + flaky_fix_${{ matrix.branch }}.json backport: - needs: check - if: ${{ github.event_name == 'schedule' || inputs.create_pr == true }} + needs: [check, setup] + if: ${{ github.event_name == 'schedule' || github.event.inputs.create_pr == true }} + strategy: + matrix: + branch: ${{ fromJSON(needs.setup.outputs.branches) }} runs-on: [self-hosted, altinity-on-demand, altinity-style-checker] steps: - name: Check out repository @@ -76,6 +100,9 @@ jobs: fetch-depth: 1 filter: blob:none + - name: Checkout target branch + run: git checkout ${{ matrix.branch }} + - name: Set up Python uses: actions/setup-python@v4 with: @@ -84,7 +111,7 @@ jobs: - name: Download check results uses: actions/download-artifact@v4 with: - name: flaky-fix-check + name: flaky-fix-check-${{ matrix.branch }} - name: Configure git identity run: | @@ -102,5 +129,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python3 tests/ci/backport_flaky_fixes.py \ - --json-file flaky_fix.json \ + --json-file flaky_fix_${{ matrix.branch }}.json \ --repo "${{ github.repository }}" From 6b672e07b6786d936f8bc8f1f23e384781a7401b Mon Sep 17 00:00:00 2001 From: strtgbb <146047128+strtgbb@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:45:13 -0400 Subject: [PATCH 5/5] fixes for backport wf: failed commit list multiple open PRs and runner type --- .github/workflows/flaky_fix_sync.yml | 2 +- tests/ci/backport_flaky_fixes.py | 35 ++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.github/workflows/flaky_fix_sync.yml b/.github/workflows/flaky_fix_sync.yml index 4065e89cba2e..569fe87eb7d0 100644 --- a/.github/workflows/flaky_fix_sync.yml +++ b/.github/workflows/flaky_fix_sync.yml @@ -30,7 +30,7 @@ on: jobs: setup: - runs-on: ubuntu-latest + runs-on: [self-hosted, altinity-on-demand, altinity-style-checker] outputs: branches: ${{ steps.set_branches.outputs.branches }} steps: diff --git a/tests/ci/backport_flaky_fixes.py b/tests/ci/backport_flaky_fixes.py index 9a18c57cf454..3b16bc3eb826 100755 --- a/tests/ci/backport_flaky_fixes.py +++ b/tests/ci/backport_flaky_fixes.py @@ -129,33 +129,41 @@ def build_pr_body( def find_open_backport_pr(repo: str, base_branch: str): """Return (number, head_branch) of an open flaky-fix backport PR for this base branch, or (None, None). Lets a weekly run amend last week's PR instead of - opening a fresh one each time.""" + opening a fresh one each time. If several match, reuses the newest.""" prefix = f"flaky-fix-backport/{base_branch}/" result = subprocess.run( ["gh", "pr", "list", "--repo", repo, "--state", "open", "--base", base_branch, - "--json", "number,headRefName", "--limit", "100"], + "--json", "number,headRefName,createdAt", "--limit", "100"], text=True, capture_output=True, ) if result.returncode != 0: print(f"Warning: gh pr list failed, will open a new PR:\n{result.stderr}", file=sys.stderr) return None, None - for pr in json.loads(result.stdout): - if pr["headRefName"].startswith(prefix): - return pr["number"], pr["headRefName"] - return None, None + matches = [pr for pr in json.loads(result.stdout) if pr["headRefName"].startswith(prefix)] + if not matches: + return None, None + matches.sort(key=lambda pr: pr["createdAt"], reverse=True) + if len(matches) > 1: + others = ", ".join(f"#{pr['number']}" for pr in matches[1:]) + print( + f"Warning: {len(matches)} open flaky-fix backport PRs for {base_branch}; " + f"reusing newest #{matches[0]['number']}, leaving {others} untouched.", + file=sys.stderr, + ) + return matches[0]["number"], matches[0]["headRefName"] -def pr_backport_commits(repo: str, number: int) -> list: +def pr_backport_commits(repo: str, number: int): """Return [(upstream_sha_or_None, subject)] for commits already on the PR, oldest-first, by reading the `(cherry picked from commit )` trailer that - `cherry-pick -x` records.""" + `cherry-pick -x` records. Returns None if the PR commits could not be read.""" result = subprocess.run( ["gh", "pr", "view", str(number), "--repo", repo, "--json", "commits"], text=True, capture_output=True, ) if result.returncode != 0: - print(f"Warning: could not read commits of PR #{number}:\n{result.stderr}", file=sys.stderr) - return [] + print(f"Error: could not read commits of PR #{number}:\n{result.stderr}", file=sys.stderr) + return None out = [] for c in json.loads(result.stdout).get("commits", []): body = c.get("messageBody", "") or "" @@ -266,6 +274,13 @@ def main() -> None: if reuse_branch: print(f"Reusing open PR #{reuse_number} ({reuse_branch}).", file=sys.stderr) prior_commits = pr_backport_commits(args.repo, reuse_number) + if prior_commits is None: + print( + f"Refusing to reuse PR #{reuse_number} without its commit list " + f"(would risk re-applying or rewriting the PR body).", + file=sys.stderr, + ) + sys.exit(1) prior_shas = {sha for sha, _ in prior_commits if sha} prior_applied = [(sha, subject, None) for sha, subject in prior_commits] missing = [c for c in missing if c["sha"] not in prior_shas]