This is an automated email from the ASF dual-hosted git repository.
uros-b pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 5442bab81485 [SPARK-58561][INFRA] Support backporting a PR merged into
a non-default branch
5442bab81485 is described below
commit 5442bab8148567d669b210833fa5272cd4bf85af
Author: Uros Bojanic <[email protected]>
AuthorDate: Fri Aug 7 09:34:56 2026 +0000
[SPARK-58561][INFRA] Support backporting a PR merged into a non-default
branch
### What changes were proposed in this pull request?
Make `dev/merge_spark_pr.py` able to backport a PR that was merged into a
non-default branch, and default its cherry-pick prompt to a branch that does
not already have the change.
Backport mode is entered when the script finds the commit that merged an
already-closed PR. That lookup previously read the merge commit off the PR's
`closed` event:
```python
merge_commits = [e for e in pr_events if e["event"] == "closed" and
e["commit_id"] is not None]
```
GitHub only attributes a commit to the `closed` event when that commit
lands on the **default branch**, because the `Closes #N` keyword in the commit
message is what closes the PR and the keyword is honored only there. A PR
merged into any other branch -- e.g. one opened against a rolling `branch-M.x`
-- is instead closed by this script through the API (`close_pr`), and that
`closed` event carries no commit. The merge then survives only as a
`referenced` event, so `merge_commits` came [...]
This adds two helpers:
- `find_merge_commit(pr_num, pr_events)` prefers the `closed` event's
commit (unchanged behavior, and GitHub's own authoritative link), and only when
that is absent falls back to `referenced` events. Since a `referenced` event is
raised by any commit merely mentioning the PR, each fallback candidate is
confirmed against the `Closes #N from ` line that `merge_pr` writes into every
merge commit.
- `default_pick_branch(branch_names, already_picked)` returns the
highest-ranked release branch that has not already received the change.
Backport mode previously defaulted the prompt to `branch_names[0]`, which for a
PR merged into `branch-M.x` is that very branch: accepting the default asked
git to cherry-pick a commit onto the branch that already had it, which fails
with `The previous cherry-pick is now empty` and lands the committer in the
"Would you like to manually fix-up this m [...]
### Why are the changes needed?
A PR opened against `branch-4.x` and merged there cannot currently be
backported to `branch-4.3` with the merge script at all -- the script's own
API-close erases the trail that its backport mode depends on. Concretely, for
#57713 (merged into `branch-4.x` as `881e5a94a15`), re-running the script
prints:
```
Start to merge pull request #57713
Pull request 57713 is not mergeable in its current form.
Continue? (experts only!) (y/N):
```
Answering `y` there would create a duplicate squash commit on `branch-4.x`;
the cherry-pick prompt is never reached. The only recourse was a manual `git
cherry-pick -sx` outside the tool, which also skips the merge comment the
script would post. With this change the same invocation reaches:
```
Pull request 57713 has already been merged, assuming you want to backport
Found commit 881e5a94a15...
Enter a branch name [branch-4.3]:
```
### Does this PR introduce _any_ user-facing change?
No. `dev/merge_spark_pr.py` is a committer tool and is not part of any
released artifact.
### How was this patch tested?
- Added doctests for both new helpers; the script runs `doctest.testmod()`
on startup. The suite goes from 68 to 76 passing tests, 0 failures.
- Replayed the real GitHub event payloads for three PRs with `get_json`
stubbed to read genuine commit messages from a local clone (no network),
asserting the resolved merge commit:
| PR | Base | closed / referenced events | Resolved | |
|---|---|---|---|---|
| #57713 | `branch-4.x` | 0 / 1 | `881e5a94a15` | fixed by this PR |
| #57696 | `master` | 1 / 2 | `f0e2b19b82d` | unchanged |
| #57682 | `master` | 1 / 2 | `bf57ee78058` | unchanged |
- Verified the fallback rejects false positives: #57696's `referenced` list
also contains `881e5a94a15` (#57713's commit, which mentions it). With #57696's
`closed` commit stripped to force the fallback, the marker check still resolves
to `f0e2b19b82d`.
- Verified a PR closed without merging yields `(None, None)`, so it does
not wrongly enter backport mode.
- Verified the merge-path refactor is behavior-preserving by transcribing
the previous `remaining_branches` logic and diffing its prompt-default sequence
against `default_pick_branch` across five multi-pick scenarios, including the
Upstream-First two-branch path and a mid-list `target_ref`; all sequences match.
- Verified passing `already_picked=(target_ref,)` in backport mode leaves
the Upstream-First policy prompt unchanged by comparing
`_upstream_first_sibling` under the old and new arguments for every (target,
pick) pair.
Note: `ruff` could not be run in the authoring environment (no PyPI
access); formatting follows the surrounding conventions in the file and is left
to CI's lint job to confirm.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
Closes #57764 from uros-b/merge-script-backport-nondefault.
Authored-by: Uros Bojanic <[email protected]>
Signed-off-by: Uros Bojanic <[email protected]>
---
dev/merge_spark_pr.py | 174 ++++++++++++++++++++++++++-----
dev/pr_merge_status.py | 57 ++++------
dev/spark_merge_footer.py | 223 ++++++++++++++++++++++++++++++++++++++++
dev/sparktestsupport/modules.py | 1 +
4 files changed, 388 insertions(+), 67 deletions(-)
diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py
index 454e390d2c01..1e8b13d70e74 100755
--- a/dev/merge_spark_pr.py
+++ b/dev/merge_spark_pr.py
@@ -48,6 +48,11 @@ from urllib.request import urlopen
from urllib.request import Request
from urllib.error import HTTPError
+# Shared with dev/pr_merge_status.py so the two committer tools agree on where
a PR landed.
+# Importable because Python puts this script's own directory first on sys.path.
+from spark_merge_footer import branches_with_merge_footer as
_branches_with_merge_footer
+from spark_merge_footer import has_merge_footer
+
try:
import jira.client
@@ -415,6 +420,55 @@ def get_json(url):
sys.exit(-1)
+def merge_commit_candidates(pr_events):
+ """Split `pr_events` into (closed_commits, referenced_commits), each
oldest-first.
+
+ Ordered by time so that a PR reopened and merged again yields its latest
merge last.
+
+ >>> merge_commit_candidates([{"event": "closed", "commit_id": "a",
"created_at": "t2"},
+ ... {"event": "referenced", "commit_id": "b",
"created_at": "t1"}])
+ (['a'], ['b'])
+ >>> merge_commit_candidates([{"event": "closed", "commit_id": None,
"created_at": "t1"}])
+ ([], [])
+ >>> merge_commit_candidates([{"event": "referenced", "commit_id": "c",
"created_at": "t2"},
+ ... {"event": "referenced", "commit_id": "b",
"created_at": "t1"}])
+ ([], ['b', 'c'])
+ """
+
+ def commits_of(event_name):
+ matched = [e for e in pr_events if e["event"] == event_name and
e["commit_id"] is not None]
+ return [e["commit_id"] for e in sorted(matched, key=lambda x:
x["created_at"])]
+
+ return commits_of("closed"), commits_of("referenced")
+
+
+def find_merge_commit(pr_num, pr_events):
+ """Return (hash, message) of the commit that merged `pr_num`, or (None,
None).
+
+ GitHub attributes the merge commit to the `closed` event only when that
commit lands
+ on the default branch (master), because the "Closes #N" keyword in the
commit message
+ is what closes the PR and the keyword is honored only there. A PR merged
into any
+ other branch -- e.g. one opened against a rolling branch-M.x -- is instead
closed by
+ this script through the API, and that `closed` event carries no commit, so
the merge
+ survives only as a `referenced` event. Prefer the `closed` commit, which
GitHub itself
+ linked; otherwise fall back to `referenced` events, which are also raised
by any commit
+ merely mentioning the PR, so confirm each against the merge footer
`merge_pr` generates.
+ """
+
+ def message_of(commit_hash):
+ return get_json("%s/commits/%s" % (GITHUB_API_BASE,
commit_hash))["commit"]["message"]
+
+ closed_commits, referenced_commits = merge_commit_candidates(pr_events)
+ if closed_commits:
+ return closed_commits[-1], message_of(closed_commits[-1])
+
+ for commit_hash in reversed(referenced_commits):
+ message = message_of(commit_hash)
+ if has_merge_footer(message, pr_num):
+ return commit_hash, message
+ return None, None
+
+
def close_pr(pr_num):
url = "%s/pulls/%s" % (GITHUB_API_BASE, pr_num)
data = json.dumps({"state": "closed"}).encode("utf-8")
@@ -615,6 +669,45 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
return pick_ref, pick_hash
+def branches_with_merge_footer(pr_num, branch_names):
+ """Release branches from `branch_names` that already carry `pr_num`'s
merge footer.
+
+ Thin wrapper over the shared reader in `spark_merge_footer`, adding this
script's own
+ policy: a git failure here must not abort a merge that may already have
pushed, so it
+ warns and reports nothing rather than exiting. Per that module's refresh
policy no fetch
+ is issued, so a backport not yet fetched into PUSH_REMOTE_NAME's tracking
refs is simply
+ not reported -- the committer is still prompted and can type any branch.
+ """
+ try:
+ landed = _branches_with_merge_footer(
+ pr_num, PUSH_REMOTE_NAME, lambda args: run_cmd(["git"] + args)
+ )
+ except Exception as e:
+ print_error("Could not scan for existing backports of #%s (%s)." %
(pr_num, e))
+ return []
+ # Keep branch_names' newest-first order, and drop anything not a known
release branch.
+ return [b for b in branch_names if b in landed]
+
+
+def default_pick_branch(branch_names, already_picked):
+ """Highest-ranked release branch that has not already received the change,
or None.
+
+ `branch_names` is ordered newest-first (see `semver_branch_rank`) and
`already_picked`
+ holds the branches the change is known to be on, so the prompt never
defaults to a
+ branch where the cherry-pick would come up empty. Returns None when every
known branch
+ already has it, so callers can say so instead of offering an empty pick.
+
+ >>> default_pick_branch(["branch-4.x", "branch-4.3", "branch-4.2"],
("branch-4.x",))
+ 'branch-4.3'
+ >>> default_pick_branch(["branch-4.x", "branch-4.3"], ())
+ 'branch-4.x'
+ >>> default_pick_branch(["branch-4.x"], ("branch-4.x",)) is None
+ True
+ """
+ remaining = [b for b in branch_names if b not in already_picked]
+ return remaining[0] if remaining else None
+
+
def _upstream_first_sibling(target_ref, pick_ref, branch_names,
already_picked):
"""Return the sibling branch-M.x if Upstream-First should prompt, else
None.
@@ -1663,17 +1756,15 @@ def main():
# Merged pull requests don't appear as merged in the GitHub API;
# Instead, they're closed by committers.
- merge_commits = [e for e in pr_events if e["event"] == "closed" and
e["commit_id"] is not None]
-
- if merge_commits and pr["state"] == "closed":
- # A PR might have multiple merge commits, if it's reopened and merged
again. We shall
- # cherry-pick PRs in closed state with the latest merge hash.
- # If the PR is still open(reopened), we shall not cherry-pick it but
perform the normal
- # merge as it could have been reverted earlier.
- merge_commits = sorted(merge_commits, key=lambda x: x["created_at"])
- merge_hash = merge_commits[-1]["commit_id"]
- message = get_json("%s/commits/%s" % (GITHUB_API_BASE,
merge_hash))["commit"]["message"]
-
+ # A PR might have multiple merge commits, if it's reopened and merged
again. We shall
+ # cherry-pick PRs in closed state with the latest merge hash.
+ # If the PR is still open (reopened), we shall not cherry-pick it but
perform the normal
+ # merge as it could have been reverted earlier.
+ merge_hash, message = (None, None)
+ if pr["state"] == "closed":
+ merge_hash, message = find_merge_commit(pr_num, pr_events)
+
+ if merge_hash is not None:
print("Pull request %s has already been merged, assuming you want to
backport" % pr_num)
commit_is_downloaded = (
run_cmd(["git", "rev-parse", "--quiet", "--verify", "%s^{commit}"
% merge_hash]).strip()
@@ -1683,11 +1774,41 @@ def main():
fail("Couldn't find any merge commit for #%s, you may need to
update HEAD." % pr_num)
print("Found commit %s:\n%s" % (merge_hash, message))
- default = branch_names[0]
- picked = cherry_pick(
- pr_num, merge_hash, default, branch_names, target_ref,
already_picked=()
- )
- post_merge_comment(pr_num, picked)
+ # The change is already on target_ref and on any branch a previous run
backported it
+ # to, so exclude all of them: defaulting to one would cherry-pick an
empty commit.
+ picked_refs = [target_ref] + [
+ b for b in branches_with_merge_footer(pr_num, branch_names) if b
!= target_ref
+ ]
+ if len(picked_refs) > 1:
+ print("Already backported to: %s" % ", ".join(picked_refs[1:]))
+ # Loop so one invocation can reach several maintenance branches, as
the merge path does.
+ picked_commits = []
+ try:
+ while True:
+ default = default_pick_branch(branch_names, tuple(picked_refs))
+ if default is None:
+ print(
+ "Every known release branch already contains #%s;
nothing to pick." % pr_num
+ )
+ break
+ picked = cherry_pick(
+ pr_num,
+ merge_hash,
+ default,
+ branch_names,
+ target_ref,
+ already_picked=tuple(picked_refs),
+ )
+ picked_refs = picked_refs + [ref for ref, _ in picked]
+ picked_commits = picked_commits + picked
+ prompt = "Would you like to pick %s into another branch?" %
merge_hash
+ if get_input(f"\n{prompt} (y/N): ", ["y", "n", ""]) != "y":
+ break
+ finally:
+ # Report whatever was pushed even if a later pick is aborted,
since the earlier
+ # pushes have already landed.
+ if picked_commits:
+ post_merge_comment(pr_num, picked_commits)
sys.exit(0)
if not bool(pr["mergeable"]):
@@ -1765,19 +1886,20 @@ def main():
# then each cherry-pick target as it is picked.
merged_commits = [(target_ref, merge_hash)]
- # Walk a mutable remaining-branches list so the next default correctly
skips any
- # branches already picked, including branches consumed by the
Upstream-First two-branch
- # path inside cherry_pick (e.g. picking branch-M.x + branch-M.N in a
single prompt).
- # merged_refs doubles as the already_picked set passed to cherry_pick: it
starts with
- # target_ref (the merge sink, never to be re-picked) and grows with every
cherry-pick.
- remaining_branches = [b for b in branch_names if b != target_ref]
+ # merged_refs drives both the next prompt default and the already_picked
set passed to
+ # cherry_pick, so each grows with every cherry-pick -- including branches
consumed by the
+ # Upstream-First two-branch path inside cherry_pick (e.g. picking
branch-M.x + branch-M.N
+ # in a single prompt). It starts with target_ref, the merge sink, never to
be re-picked.
pick_prompt = "Would you like to pick %s into another branch?" % merge_hash
# Always record the merge summary for what actually landed, even if a later
# cherry-pick is aborted or cancelled: the merge into the target branch has
# already been pushed, so cancelling a backport must not drop that line.
try:
while get_input(f"\n{pick_prompt} (y/N): ", ["y", "n", ""]) == "y":
- default = remaining_branches[0] if remaining_branches else
branch_names[0]
+ default = default_pick_branch(branch_names, tuple(merged_refs))
+ if default is None:
+ print("Every known release branch already contains #%s;
nothing to pick." % pr_num)
+ break
picked = cherry_pick(
pr_num,
merge_hash,
@@ -1786,12 +1908,8 @@ def main():
target_ref,
already_picked=tuple(merged_refs),
)
- picked_refs = [ref for ref, _ in picked]
- merged_refs = merged_refs + picked_refs
+ merged_refs = merged_refs + [ref for ref, _ in picked]
merged_commits = merged_commits + picked
- for b in picked_refs:
- if b in remaining_branches:
- remaining_branches.remove(b)
finally:
if merged_commits:
# The "Closes #N" keyword in the commit message only auto-closes
the PR when the
diff --git a/dev/pr_merge_status.py b/dev/pr_merge_status.py
index 510409f22500..d4e95f55c14f 100755
--- a/dev/pr_merge_status.py
+++ b/dev/pr_merge_status.py
@@ -60,6 +60,11 @@ import re
import subprocess
import sys
+# Shared with dev/merge_spark_pr.py so the two committer tools cannot disagree
about where a
+# PR landed. Importable because Python puts this script's own directory first
on sys.path.
+from spark_merge_footer import branches_with_merge_footer
+from spark_merge_footer import merge_footer_trailer
+
REPO = "apache/spark"
@@ -177,38 +182,6 @@ def fetch_branches(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."""
@@ -257,19 +230,25 @@ def main():
# 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]
+ # The shared reader consumes local remote-tracking refs only, so
fetch_branches above is
+ # what refreshes them -- best-effort, since it warns and continues on a
failed fetch,
+ # leaving the refs possibly stale (a recently merged PR could look
unmerged).
+ all_landed = branches_with_merge_footer(pr, remote, lambda args:
git(*args))
+ landed = {
+ branch: commit[:11]
+ for branch, commit in all_landed.items()
+ if all_branches or is_relevant(branch, majors)
+ }
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)
+ print(
+ 'closed without merging -- no "%s" commit found (rejected or
superseded).'
+ % merge_footer_trailer(pr)
+ )
if __name__ == "__main__":
diff --git a/dev/spark_merge_footer.py b/dev/spark_merge_footer.py
new file mode 100644
index 000000000000..567895b716ad
--- /dev/null
+++ b/dev/spark_merge_footer.py
@@ -0,0 +1,223 @@
+#!/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.
+#
+
+"""
+Shared reader for the merge footer that `dev/merge_spark_pr.py` writes into
every commit
+it creates, so the committer tools cannot disagree about where a pull request
landed.
+
+`merge_pr` ends each message it generates with
+
+ Closes #<pr> from <author>/<branch>.
+
+ Authored-by: A <[email protected]>
+ Signed-off-by: C <[email protected]>
+
+and `git cherry-pick -x` copies that footer verbatim into every backport,
appending its own
+provenance lines after it. The footer is therefore the signal that identifies
both a merge
+and its backports -- `git ... --contains <merge_hash>` cannot, because a
cherry-pick is a
+new commit that no other branch contains.
+
+Two properties make reading it reliable, and both are easy to get wrong:
+
+- A PR body is passed through as its own `git commit -m` paragraph, so it may
quote another
+ commit's footer in full, structure included. Only *position* distinguishes
the generated
+ footer: `merge_pr` appends it last, so the generated one is the final
"Closes" paragraph.
+- `git log --grep` matches its pattern anywhere in a message, so it can only
narrow the
+ walk; every candidate it returns must still be validated with
`has_merge_footer`.
+
+When imported, nothing here exits, prints, or runs git: callers pass a
`run_git` callable and
+so keep their own error-handling policy -- `dev/pr_merge_status.py` exits on a
git failure,
+while `dev/merge_spark_pr.py` must not abort a merge in progress. (Running
this file directly
+executes its doctests and exits nonzero if any fail.)
+
+Refresh policy: `branches_with_merge_footer` reads local remote-tracking refs
only and
+never fetches. A caller that needs current data fetches first (as
`pr_merge_status.py`
+does, best-effort); a caller that must not touch the network mid-run simply
accepts that a
+branch not yet fetched goes unreported.
+"""
+
+import re
+
+# The generated footer: a "Closes #<pr> from <ref>" line alone on its
paragraph, followed by
+# the authors paragraph. `\s*$` tolerates trailing whitespace. Requiring the
blank line and
+# the authors line rejects prose that merely mentions a PR; taking the *last*
match (see
+# `merge_footer_pr`) rejects a body that quotes a real footer.
+_MERGE_FOOTER_RE = re.compile(
+ r"^Closes #(\d+) from \S+\s*$\n\n(?:Lead-authored-by|Authored-by):",
+ re.MULTILINE,
+)
+
+
+def merge_footer_trailer(pr_num):
+ """The literal fragment to pass to `git log --fixed-strings --grep`.
+
+ Only a prefilter to narrow the walk: it matches anywhere in a message, so
callers
+ validate each candidate with `has_merge_footer`.
+
+ >>> merge_footer_trailer(1)
+ 'Closes #1 from '
+ """
+ return "Closes #%s from " % pr_num
+
+
+def merge_footer_pr(message):
+ """The PR number in `message`'s generated merge footer, or None if it has
none.
+
+ Reads the *last* "Closes" paragraph, since a PR body copied into the
message may quote
+ an earlier one. Cherry-pick provenance lines may follow the footer, but no
later
+ "Closes" paragraph can.
+
+ >>> footer = "Closes #1 from a/b.\\n\\nAuthored-by: A
<[email protected]>\\nSigned-off-by: C <[email protected]>"
+ >>> merge_footer_pr("[SPARK-1][SQL] Title\\n\\nSome body.\\n\\n" + footer)
+ 1
+ >>> merge_footer_pr("[SPARK-1][SQL] Title\\n\\n" +
footer.replace("Authored", "Lead-authored"))
+ 1
+ >>> merge_footer_pr("[SPARK-1][SQL] Title\\n\\nNo footer here.") is None
+ True
+
+ A cherry-pick keeps the footer, with `-x` provenance appended after it:
+
+ >>> pick = footer + "\\n(cherry picked from commit
abc123)\\nSigned-off-by: C <[email protected]>"
+ >>> merge_footer_pr("[SPARK-1][SQL] Title\\n\\n" + pick)
+ 1
+
+ A body quoting another PR's complete footer does not shadow the real one:
+
+ >>> quoted = "Reverting:\\n\\n" + footer + "\\n\\nSee above."
+ >>> own = footer.replace("#1", "#2")
+ >>> merge_footer_pr("[SPARK-2][SQL] Later\\n\\n%s\\n\\n%s" % (quoted, own))
+ 2
+ """
+ matches = _MERGE_FOOTER_RE.findall(message)
+ return int(matches[-1]) if matches else None
+
+
+def has_merge_footer(message, pr_num):
+ """Whether `message`'s generated merge footer closes `pr_num`. See
`merge_footer_pr`.
+
+ `pr_num` may be an int or a string of digits: callers get the PR number
from argv or from
+ the GitHub API, and comparing those two forms directly would silently
never match.
+
+ >>> footer = "Closes #1 from a/b.\\n\\nAuthored-by: A
<[email protected]>\\nSigned-off-by: C <[email protected]>"
+ >>> has_merge_footer("[SPARK-1][SQL] Title\\n\\n" + footer, 1)
+ True
+ >>> has_merge_footer("[SPARK-1][SQL] Title\\n\\n" + footer, "1")
+ True
+ >>> has_merge_footer("[SPARK-1][SQL] Title\\n\\n" + footer, 2)
+ False
+
+ A commit whose body quotes another PR's full footer is not taken for that
PR's merge:
+
+ >>> quoted = "Reverting:\\n\\n" + footer + "\\n\\nSee above."
+ >>> later = "[SPARK-2][SQL] Later\\n\\n%s\\n\\n%s" % (quoted,
footer.replace("#1", "#2"))
+ >>> has_merge_footer(later, 1)
+ False
+ >>> has_merge_footer(later, 2)
+ True
+ """
+ return merge_footer_pr(message) == int(pr_num)
+
+
+def parse_commit_records(out):
+ """Parse `git log --format='%H %B%x00'` output into (commit_hash, message)
pairs.
+
+ A commit message spans lines, so records are NUL-delimited rather than
newline-delimited.
+
+ >>> parse_commit_records("abc first\\nline two\\x00def second\\x00")
+ [('abc', 'first\\nline two'), ('def', 'second')]
+ >>> parse_commit_records("")
+ []
+ """
+ records = []
+ for record in out.split("\0"):
+ record = record.strip("\n")
+ if not record:
+ continue
+ commit_hash, _, message = record.partition(" ")
+ records.append((commit_hash, message))
+ return records
+
+
+def branch_names_from_refs(out, remote):
+ """Branch names in `git for-each-ref --format='%(refname:short)'` output
for `remote`.
+
+ Real branches are "<remote>/<branch>"; the remote's HEAD symref shortens
to the bare
+ remote name, so anything without the "<remote>/" prefix is skipped, as is
the explicit
+ "<remote>/HEAD" form.
+
+ >>>
sorted(branch_names_from_refs("up/master\\nup/branch-4.x\\nup\\nup/HEAD\\n",
"up"))
+ ['branch-4.x', 'master']
+ """
+ prefix = remote + "/"
+ names = set()
+ for ref in out.splitlines():
+ if not ref.startswith(prefix):
+ continue
+ name = ref[len(prefix) :]
+ if name != "HEAD":
+ names.add(name)
+ return names
+
+
+def branches_with_merge_footer(pr_num, remote, run_git):
+ """Map each `remote` branch carrying `pr_num`'s merge footer to the commit
that has it.
+
+ `run_git(args)` runs `git` with `args` and returns its stdout; the caller
supplies it so
+ this module imposes no error-handling or exit policy of its own. Reads
local
+ remote-tracking refs only -- see this module's refresh policy.
+
+ Scoping the walk to `--remotes=<remote>` keeps fork refs and tags from
adding noise or
+ cost. Every commit `--grep` returns is validated before its branches
count, so a commit
+ that merely quotes the trailer cannot make a branch look like it has the
change.
+ """
+ out = run_git(
+ [
+ "log",
+ "--remotes=%s" % remote,
+ "--fixed-strings",
+ "--grep",
+ merge_footer_trailer(pr_num),
+ "--format=%H %B%x00",
+ ]
+ )
+ landed = {}
+ for commit_hash, message in parse_commit_records(out):
+ if not has_merge_footer(message, pr_num):
+ continue
+ refs = run_git(
+ [
+ "for-each-ref",
+ "--contains",
+ commit_hash,
+ "--format=%(refname:short)",
+ "refs/remotes/%s/" % remote,
+ ]
+ )
+ for branch in branch_names_from_refs(refs, remote):
+ landed[branch] = commit_hash
+ return landed
+
+
+if __name__ == "__main__":
+ import doctest
+ import sys
+
+ failure_count, test_count = doctest.testmod()
+ if failure_count:
+ sys.exit(-1)
diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py
index 1f1e4604f6d2..b0c4d6e85444 100644
--- a/dev/sparktestsupport/modules.py
+++ b/dev/sparktestsupport/modules.py
@@ -1761,6 +1761,7 @@ dev_tools = Module(
"dev/merge_spark_pr.py",
"dev/requirements.txt",
"dev/pr_merge_status.py",
+ "dev/spark_merge_footer.py",
"dev/create_spark_jira.py",
"dev/create-release/",
],
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]