This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 681d005c0b49 [SPARK-57674][INFRA] Add dev/pr_merge_status.py and
document PR merge-status checking in AGENTS.md
681d005c0b49 is described below
commit 681d005c0b49651004371860370cd0061ff94d62
Author: Wenchen Fan <[email protected]>
AuthorDate: Thu Jun 25 08:19:42 2026 +0200
[SPARK-57674][INFRA] Add dev/pr_merge_status.py and document PR
merge-status checking in AGENTS.md
### What changes were proposed in this pull request?
Two related changes that help readers (and AI agents) correctly determine
the merge status of a Spark PR:
1. **New `dev/pr_merge_status.py <pr-number>` helper.** Given a PR number,
it reports which `apache/spark` branches the PR landed on:
```
$ dev/pr_merge_status.py 56356
PR #56356: [SPARK-57295][SQL] Make database location validation
consistent ...
merged: yes
master 9357bc9ae05
branch-4.x 72edddb358e
```
How it works:
- It keys on the `Closes #<pr> from <author>/<branch>` trailer that
`dev/merge_spark_pr.py` writes into the `master` merge commit and that every
cherry-pick retains -- so it does not depend on GitHub's `merged` flag (empty
for script-merged PRs).
- It first asks GitHub via `gh` whether the PR is open; an open PR
cannot have merged anywhere, so it returns immediately without touching git.
Otherwise it fetches `master` and the `branch-*` refs from the `apache/spark`
remote and reads branch containment from local git. (Needs `gh` authenticated
and a non-shallow checkout.)
- It lists `master` and the latest major's branches (e.g. `branch-4.x`,
`branch-4.2`, ... today); older end-of-life lines aren't listed alongside them,
but a PR that landed *only* on an older line (e.g. a `branch-3.5`-only
backport) is still reported as such, never mistaken for unmerged.
- The apache/spark remote is auto-detected by URL, preferring one named
`upstream` (the AGENTS.md convention) and SSH over HTTPS.
2. **New `## Checking PR Merge Status` section in `AGENTS.md`**
(`CLAUDE.md` is a symlink to it), next to "Investigating PR CI Failures". It
documents why a merged Spark PR shows up on GitHub as **Closed** rather than
**Merged** and points at the helper.
### Why are the changes needed?
A merged Spark PR shows up as "Closed" on GitHub rather than "Merged"
(Spark merges with `dev/merge_spark_pr.py`, not the GitHub button), which is
routinely misread -- by AI coding agents in particular, but also by newcomers
-- as the PR having been rejected. There is also no documented, mechanical way
to check whether, and to which branches, a given PR was actually merged.
`AGENTS.md` already documents the PR workflow and versioning/branch policy, and
`dev/` already hosts a parallel [...]
### Does this PR introduce _any_ user-facing change?
No. This adds a developer helper script and documentation only; no Spark
runtime behavior changes.
### How was this patch tested?
Ran `dev/pr_merge_status.py` across the full range of cases and
cross-checked each against `git log --grep "Closes #<pr> from "` + `git
merge-base --is-ancestor`:
- merged to `master` only; merged to `master` + multiple maintenance
branches;
- backport PRs whose base branch is `branch-4.0` / `branch-4.1` /
`branch-4.2` / `branch-4.x` (reported on the target branch);
- a `branch-3.5`-only backport (reported as merged outside the latest
major, not as unmerged);
- open PR (short-circuits to "open"); a non-existent PR number (reported
"not found"); invalid / missing arguments.
`dev/pr_merge_status.py` passes `black` (the repo-pinned version, line
length 100); the change is otherwise documentation.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #56752 from cloud-fan/improve-agents-md-pr-merge-detection.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Uros Bojanic <[email protected]>
---
AGENTS.md | 14 +++
dev/pr_merge_status.py | 276 +++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 290 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 5bdda2578340..7f038fd44a06 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -119,6 +119,20 @@ Step 3 — Fetch failure annotations:
Each annotation contains the test class, test name, and failure message.
+## Checking PR Merge Status
+
+Spark merges PRs with `dev/merge_spark_pr.py`, not the GitHub merge button, so
a **merged PR shows up on GitHub as Closed, not Merged** — its `merged` /
`mergedAt` are empty, and backports to maintenance branches are plain pushes.
**Do not read a Closed PR as rejected**; most Closed PRs were in fact merged.
+
+To check whether, and to which branches, a PR was merged, run
`dev/pr_merge_status.py <pr-number>`:
+
+ $ dev/pr_merge_status.py 56356
+ PR #56356 (base master): [SPARK-57295][SQL] Make database location
validation ...
+ merged: yes
+ master 9357bc9ae05
+ branch-4.x 72edddb358e
+
+It lists `master` and the latest major's release branches the commit reached
(e.g. `branch-4.x`, `branch-4.2`) — or reports `open` or `closed without
merging`. It needs `gh` authenticated and a non-shallow checkout, and exits
non-zero with a clear message if the PR is unknown (404) or the environment
isn't ready (no `gh`/auth, no apache/spark remote). Older majors' branches are
omitted; pass `--all-branches` to list every branch the commit reached.
+
## Pull Request Workflow
PR title format is `[SPARK-xxxx][COMPONENT] Title`. The component tag is
derived from the JIRA component name: take the last word and uppercase it (e.g.
`Project Infra` → `[INFRA]`, `Spark Core` → `[CORE]`, `Structured Streaming` →
`[STREAMING]`, `SQL` → `[SQL]`).
diff --git a/dev/pr_merge_status.py b/dev/pr_merge_status.py
new file mode 100755
index 000000000000..510409f22500
--- /dev/null
+++ b/dev/pr_merge_status.py
@@ -0,0 +1,276 @@
+#!/usr/bin/env python3
+
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""
+Reports which apache/spark branches a pull request was merged to, so the
caller does
+not have to reason about it by hand (see the "Checking PR Merge Status"
section of
+AGENTS.md for why this is non-obvious):
+
+ $ dev/pr_merge_status.py 56356
+ PR #56356 (base master): [SPARK-57295][SQL] Make database location
validation ...
+ merged: yes
+ master 9357bc9ae05
+ branch-4.x 72edddb358e
+
+Spark merges with dev/merge_spark_pr.py rather than the GitHub button, so a
merged PR
+shows up as "Closed" (not "Merged") with empty merge metadata, and backports
are plain
+cherry-pick pushes. This script does not rely on that metadata. The merge
script writes
+a closing trailer "Closes #<pr> from <author>/<branch>" into the master
commit, and
+every cherry-pick of it to a maintenance branch retains that trailer; the
script finds
+the commits carrying it and reports each branch that contains one. It reports
the
+mechanical facts only -- whether a change *should* have been backported is a
judgement
+call and is NOT made here.
+
+It first asks GitHub (via the `gh` CLI) whether the PR is open: an open PR
cannot have
+merged anywhere, so it returns immediately without touching git. Otherwise it
fetches
+`master` and the `branch-*` refs from the apache/spark remote in a single pass
and reads
+branch containment from local git. So it needs `gh` (authenticated) and a
non-shallow
+checkout.
+
+It lists `master` and the latest major's release branches the commit reached
(e.g.
+branch-4.x, branch-4.2); the header repeats the base branch for context. Other
majors'
+branches are omitted to keep the focus current, except the PR's own
base-branch major, so
+a PR opened against an older release line (a backport) still shows its merge
there. Pass
+--all-branches to instead list every branch the commit reached, including
omitted older
+majors.
+
+Caveat: it reports where the PR's commits landed, not whether one was later
reverted on
+a branch (rare; visible in the PR conversation).
+
+Usage: dev/pr_merge_status.py <pr-number> [--all-branches]
+"""
+
+import re
+import subprocess
+import sys
+
+REPO = "apache/spark"
+
+
+def gh(path, jq=None, allow_404=False):
+ """Calls `gh api <path>` and returns its stdout (filtered by `jq` if
given). Returns
+ None on a 404 when `allow_404`. Exits with a clear message if `gh` is
missing,
+ unauthenticated, or otherwise fails."""
+ args = ["gh", "api", path]
+ if jq is not None:
+ args += ["--jq", jq]
+ try:
+ result = subprocess.run(args, capture_output=True, text=True,
encoding="utf-8")
+ except FileNotFoundError:
+ sys.exit(
+ "error: the GitHub CLI `gh` is required but was not found.\n"
+ " Install it from https://cli.github.com/ and run `gh auth
login`."
+ )
+ if result.returncode != 0:
+ if allow_404 and "(HTTP 404)" in result.stderr:
+ return None
+ sys.stderr.write(result.stderr)
+ sys.exit("error: `gh api %s` failed. Is `gh` authenticated? Check `gh
auth status`." % path)
+ return result.stdout
+
+
+def git(*args, check=True):
+ """Runs a git command. Returns stdout; on failure, exits (check=True) or
returns None
+ (check=False, used for the best-effort fetch)."""
+ result = subprocess.run(["git", *args], capture_output=True, text=True,
encoding="utf-8")
+ if result.returncode != 0:
+ if not check:
+ return None
+ sys.stderr.write(result.stderr)
+ sys.exit("error: command failed: git " + " ".join(args))
+ return result.stdout
+
+
+def detect_remote():
+ """Returns the name of the git remote pointing to apache/spark, or None. A
clone may
+ have several aliases for the same upstream; any one works (the script
fetches it
+ before reading, so its refs are current), and picking just one keeps the
branch
+ lookups fast. Preference order: the remote named `upstream` (the AGENTS.md
convention)
+ first; then SSH over HTTPS (key auth, no credential prompts); then
declaration order
+ in `git remote -v`."""
+ matches = [] # (name, url) for each fetch remote whose URL is apache/spark
+ seen = set()
+ for line in git("remote", "-v").splitlines():
+ parts = line.split()
+ if (
+ len(parts) >= 3
+ and parts[2] == "(fetch)"
+ and re.search(r"[:/]apache/spark(\.git)?$", parts[1])
+ and parts[0] not in seen
+ ):
+ seen.add(parts[0])
+ matches.append((parts[0], parts[1]))
+ if not matches:
+ return None
+
+ def rank(name_url):
+ name, url = name_url
+ is_https = url.startswith(("http://", "https://"))
+ return (name != "upstream", is_https)
+
+ # sorted() is stable, so ties keep `git remote -v` order.
+ return sorted(matches, key=rank)[0][0]
+
+
+def branch_major(name):
+ """Returns the major version of a `branch-<major>.<minor>` /
`branch-<major>.x` name,
+ or None for `master` and anything that isn't a release branch."""
+ match = re.fullmatch(r"branch-(\d+)\.(?:x|\d+)", name)
+ return int(match.group(1)) if match else None
+
+
+def latest_major(remote):
+ """Returns the highest release-branch major version present in `remote`'s
tracking refs,
+ or None if there is none. Call after fetch_branches: the incremental
`branch-*` fetch
+ brings in a brand-new major's branch too, so reading local refs here
reflects the remote
+ without a second network round-trip."""
+ out = git("for-each-ref", "--format=%(refname:short)",
"refs/remotes/%s/branch-*" % remote)
+ names = [ref.split("/", 1)[1] for ref in out.splitlines() if "/" in ref]
+ majors = [m for m in (branch_major(n) for n in names) if m is not None]
+ return max(majors) if majors else None
+
+
+def is_relevant(branch, majors):
+ """A branch is in scope when it is `master` or a release branch whose
major is in
+ `majors` (the latest major, plus the PR's own base-branch major)."""
+ return branch == "master" or branch_major(branch) in majors
+
+
+def fetch_branches(remote):
+ """Updates the remote-tracking refs for `master` and every `branch-*` from
`remote`, so
+ containment below reflects the latest state. Fetching all release branches
in one pass --
+ rather than first asking the remote for the latest major and fetching only
that -- is
+ intentional: an incremental fetch transfers no objects for unchanged
(end-of-life)
+ branches, so it costs the same as a targeted fetch while saving a second
network
+ round-trip, and it still brings in a brand-new major's branch.
Best-effort: a fetch
+ failure (e.g. offline) warns and falls back to whatever refs are already
local."""
+ if (
+ git(
+ "fetch",
+ "--quiet",
+ remote,
+ "+refs/heads/master:refs/remotes/%s/master" % remote,
+ "+refs/heads/branch-*:refs/remotes/%s/branch-*" % remote,
+ check=False,
+ )
+ is None
+ ):
+ sys.stderr.write(
+ "warning: `git fetch %s` failed; using already-fetched branches,
which may be "
+ "stale (a recently merged PR could look unmerged).\n" % remote
+ )
+
+
+def commits_with_trailer(trailer, remote):
+ """Returns the full SHAs of commits on `remote`'s branches whose message
contains
+ `trailer`. Scoping to the one remote (rather than `--all`) keeps fork refs
and tags
+ from adding noise or walk cost."""
+ out = git("log", "--remotes=%s" % remote, "--fixed-strings", "--grep",
trailer, "--format=%H")
+ return list(dict.fromkeys(out.split()))
+
+
+def official_branches_containing(commit, remote):
+ """Returns the `remote` branch names (e.g. 'master', 'branch-4.x') that
contain
+ `commit`, ignoring the remote's HEAD alias and any non-branch refs."""
+ out = git(
+ "for-each-ref",
+ "--contains",
+ commit,
+ "--format=%(refname:short)",
+ "refs/remotes/%s/" % remote,
+ )
+ prefix = remote + "/"
+ branches = set()
+ for ref in out.splitlines():
+ # Real branches are "<remote>/<branch>"; the remote's HEAD symref
shortens to the
+ # bare remote name (e.g. "upstream") -- skip anything without the
"<remote>/" prefix,
+ # and the explicit "<remote>/HEAD" form for good measure.
+ if not ref.startswith(prefix):
+ continue
+ name = ref[len(prefix) :]
+ if name != "HEAD":
+ branches.add(name)
+ return branches
+
+
+def display_key(name):
+ """Sorts `master` first, then branch-<major>.<minor> ascending, with
branch-<N>.x
+ (the active dev line for the next feature release) after its numeric
siblings."""
+ if name == "master":
+ return (0, 0, 0)
+ match = re.fullmatch(r"branch-(\d+)\.(x|\d+)", name)
+ if match is None:
+ return (2, 0, name)
+ minor = float("inf") if match.group(2) == "x" else int(match.group(2))
+ return (1, int(match.group(1)), minor)
+
+
+def main():
+ args = sys.argv[1:]
+ all_branches = "--all-branches" in args
+ rest = [a for a in args if a != "--all-branches"]
+ if len(rest) != 1 or rest[0].startswith("-"):
+ sys.exit("Usage: dev/pr_merge_status.py <pr-number> [--all-branches]")
+ pr = rest[0].lstrip("#")
+ if not pr.isdigit():
+ sys.exit("error: <pr-number> must be a number, got %r" % rest[0])
+
+ info = gh(
+ "repos/%s/pulls/%s" % (REPO, pr), jq="[.state, .base.ref, .title] |
@tsv", allow_404=True
+ )
+ if info is None:
+ sys.exit("error: PR #%s not found in %s" % (pr, REPO))
+ fields = info.rstrip("\n").split("\t")
+ state, base, title = fields[0], fields[1], fields[2] if len(fields) > 2
else ""
+ print("PR #%s (base %s): %s" % (pr, base, title))
+
+ # An open PR cannot have merged anywhere -- short-circuit before touching
git.
+ if state == "open":
+ print("open -- not merged yet.")
+ return
+
+ remote = detect_remote()
+ if remote is None:
+ sys.exit(
+ "error: no git remote points to apache/spark. Add one and retry:\n"
+ " git remote add upstream https://github.com/apache/spark.git"
+ )
+ fetch_branches(remote)
+ # In scope: master, the latest major's branches, and the PR's own
base-branch major --
+ # the last so a PR opened against an older line (e.g. a branch-3.5
backport) still shows
+ # its merge there, since a merge always lands on the base branch.
+ majors = {m for m in (latest_major(remote), branch_major(base)) if m is
not None}
+
+ trailer = "Closes #%s from " % pr
+ landed = {}
+ for commit in commits_with_trailer(trailer, remote):
+ for branch in official_branches_containing(commit, remote):
+ if all_branches or is_relevant(branch, majors):
+ landed[branch] = commit[:11]
+
+ if landed:
+ print("merged: yes")
+ for branch in sorted(landed, key=display_key):
+ print(" %-12s %s" % (branch, landed[branch]))
+ else:
+ print('closed without merging -- no "%s" commit found (rejected or
superseded).' % trailer)
+
+
+if __name__ == "__main__":
+ main()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]