blob: 510409f22500d1858f46069a01290e7d0a80bb52 [file]
#!/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()