spark-test commented on code in PR #59899:
URL: https://github.com/apache/spark/pull/59899#discussion_r4408045771
##########
dev/merge_spark_pr.py:
##########
@@ -946,9 +1569,68 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
else:
print("Cherry-pick already completed manually; continuing with the
backport.")
- continue_maybe(
- "Pick complete (local ref %s). Push to %s?" % (pick_branch_name,
PUSH_REMOTE_NAME)
+ maybe_binding_policy_warning(pick_ref, pick_head, cherry=True)
+ maybe_compile_check(pick_branch_name, cherry=True)
+
+ choice = get_input(
+ "Pick complete (local ref %s). How should it land?\n"
+ " [d] Push directly to %s/%s\n"
+ " [p] Push to your fork (%s) and open a draft backport PR against %s
(for CI)\n"
+ " [s] Skip this branch (default)\n"
+ "Choose [d/p/s]: "
+ % (pick_branch_name, PUSH_REMOTE_NAME, pick_ref, FORK_REMOTE_NAME,
pick_ref),
+ {"d": ["d", "direct"], "p": ["p", "pr"], "s": ["s", "skip", ""]},
)
+ if choice == "s":
+ # Restore the original checkout and drop the temp PICK_PR_* branches
before skipping,
+ # as the continue_maybe decline path does. Otherwise this branch stays
checked out and a
+ # later pick of the same ref fails when `git fetch` tries to update
the checked-out branch.
+ print("Skipping this branch; the merge continues.")
+ clean_up()
+ raise SkipCherryPick()
+
+ if choice == "p":
+ if FORK_REMOTE_NAME == PUSH_REMOTE_NAME:
Review Comment:
**Non-blocking (P2):** This guard only fires when the two remote *names*
match. `FORK_REMOTE_NAME` defaults to `origin` (73), and `_fork_owner` returns
`'apache'` for both `https://github.com/apache/spark.git` and
`[email protected]:apache/spark.git` (I ran the pattern at 1496). With
`FORK_OWNER` set, it doesn't read the URL at all. So in a clone of apache/spark
whose `origin` was never repointed (the script's header asks only for the
`apache-github` and `apache` remotes), `[p]` runs `git push origin
...:backport-pr<N>-branch-4.2` and then
`create_pr(head='apache:backport-pr<N>-branch-4.2')`. For a committer with
write access, that creates a branch and a draft PR in apache/spark, starts a
full CI run there (`build_main.yml` runs on pushes to every branch), and mails
commits@. That is exactly what the guard's message says it prevents.
Suggested fix: before pushing, resolve the fork remote's URL into
host/owner/repo and refuse apache/spark, or any repository matching the push or
PR remote. Do this whether or not `FORK_OWNER` is set, since `FORK_OWNER` only
shapes the PR head, not where the push goes.
See **Shared repair plan 2** in the review body.
##########
dev/merge_spark_pr.py:
##########
@@ -1080,14 +1767,14 @@ def cherry_pick(pr_num, merge_hash, default_branch,
branch_names, target_ref, al
# reflect it.
picked = []
try:
- picked.append(_do_cherry_pick(pr_num, merge_hash, sibling_x))
- picked.append(_do_cherry_pick(pr_num, merge_hash, pick_ref))
+ picked.append(_do_cherry_pick(pr_num, merge_hash, sibling_x,
title=title))
+ picked.append(_do_cherry_pick(pr_num, merge_hash, pick_ref,
title=title))
Review Comment:
**Non-blocking (P2):** `[p]` returns `(ref, PR URL)` or `(ref, 'fork:...')`
through the same channel as a pushed pick, and `main()` still appends every
returned ref to `merged_refs`/`picked_refs` (3292, 3410), which become
`already_picked` (3289, 3407). So once branch-4.x has gone to a draft PR,
`_upstream_first_sibling('master', 'branch-4.2', ['branch-4.x', 'branch-4.2'],
('master', 'branch-4.x'))` returns `None` (I ran it). No Upstream-First prompt
appears, and `[d]` pushes branch-4.2 while branch-4.x has only an open draft
PR. The `[b]oth` path does the same within one prompt: this line runs right
after branch-4.x returned a pending marker, and its landing prompt offers
`[d]`. `cherry_pick`'s own contract says to prompt whenever branch-M.x "has not
already received this commit" (1724).
The result is that branch-M.N can carry a change that branch-M.x lacks. That
is permanent if the draft PR is abandoned, and if the PR lands after 4.3.0 is
cut, a 4.2.x release ships a fix that 4.3.0 lacks. JIRA is resolved from
`landed_refs` `[master, branch-4.2]` to `['5.0.0', '4.2.1']`, the
maintenance-only record an explicit `[o]nly` answer produces, although nobody
chose `[o]nly`. The landed/pending split this PR added only reaches the JIRA
input and the merge comment.
See **Shared repair plan 1** in the review body.
##########
dev/merge_spark_pr.py:
##########
@@ -901,12 +1462,74 @@ def merge_pr(pr_num, target_ref, title, body,
pr_repo_desc, pr_author, co_author
return merge_hash
-def _do_cherry_pick(pr_num, merge_hash, pick_ref):
+def _backport_pr_branch(pr_num, pick_ref):
+ """Fork branch name for a backport PR: stable, PR-traceable,
branch-suffixed.
+
+ >>> _backport_pr_branch(123, "branch-4.2")
+ 'backport-pr123-branch-4.2'
+ >>> _backport_pr_branch(9, "branch-3.5")
+ 'backport-pr9-branch-3.5'
+ """
+ return "backport-pr%s-%s" % (pr_num, pick_ref)
+
+
+def _backport_pr_title(pick_ref, title):
+ """PR title for a backport PR: the merged title tagged with its target
branch.
+
+ >>> _backport_pr_title("branch-4.2", "[SPARK-1234][SQL] Fix a thing")
+ '[BACKPORT-branch-4.2] [SPARK-1234][SQL] Fix a thing'
+ """
+ return "[BACKPORT-%s] %s" % (pick_ref, title)
Review Comment:
**Non-blocking (P2):** I ran the extracted `Title.parse` on generated
titles. `[BACKPORT-branch-4.2] [SPARK-1234][SQL] Fix a thing`,
`[BACKPORT-branch-4.2] [MINOR] Fix typo` and `[BACKPORT-branch-4.2] Revert
"..."` are all rejected with `title must start with [SPARK-NNNNN], [MINOR], or
[TRIVIAL]`, which `main()` turns into `fail('Malformed PR title: ...')`. The
Revert form also no longer matches `main()`'s `startswith('Revert "')` check,
so revert handling is lost as well. Every `[p]` PR therefore has to be retitled
by hand before the script will merge it. If someone squash-merges it on GitHub
instead (enabled in `.asf.yaml` with `PR_TITLE_AND_DESC`), the `[BACKPORT-...]`
subject becomes permanent branch history.
The parser already accepts a target-line marker: `[SPARK-1234][4.2][SQL] Fix
a thing` parses, and `main()` moves version tags to the front of the
components. That gives the same 'easy to spot' visibility. Revert/Reapply
titles could stay verbatim, with the target branch named in the body.
**Verification:**
- **Regression:** Titles generated from a SPARK-id original and a [MINOR]
original are accepted by the merge path's title parser, and a title generated
from a Revert original is still detected as a revert.
##########
dev/merge_spark_pr.py:
##########
@@ -946,9 +1569,68 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
else:
print("Cherry-pick already completed manually; continuing with the
backport.")
- continue_maybe(
- "Pick complete (local ref %s). Push to %s?" % (pick_branch_name,
PUSH_REMOTE_NAME)
+ maybe_binding_policy_warning(pick_ref, pick_head, cherry=True)
+ maybe_compile_check(pick_branch_name, cherry=True)
+
+ choice = get_input(
+ "Pick complete (local ref %s). How should it land?\n"
+ " [d] Push directly to %s/%s\n"
+ " [p] Push to your fork (%s) and open a draft backport PR against %s
(for CI)\n"
+ " [s] Skip this branch (default)\n"
+ "Choose [d/p/s]: "
+ % (pick_branch_name, PUSH_REMOTE_NAME, pick_ref, FORK_REMOTE_NAME,
pick_ref),
+ {"d": ["d", "direct"], "p": ["p", "pr"], "s": ["s", "skip", ""]},
)
+ if choice == "s":
+ # Restore the original checkout and drop the temp PICK_PR_* branches
before skipping,
+ # as the continue_maybe decline path does. Otherwise this branch stays
checked out and a
+ # later pick of the same ref fails when `git fetch` tries to update
the checked-out branch.
+ print("Skipping this branch; the merge continues.")
+ clean_up()
+ raise SkipCherryPick()
+
+ if choice == "p":
+ if FORK_REMOTE_NAME == PUSH_REMOTE_NAME:
+ fail(
+ "FORK_REMOTE_NAME (%s) must differ from PUSH_REMOTE_NAME (%s);
otherwise "
+ "the backport-PR path pushes straight to the Apache remote.
Set "
+ "FORK_REMOTE_NAME to your fork remote and retry."
+ % (FORK_REMOTE_NAME, PUSH_REMOTE_NAME)
+ )
+ backport_branch = _backport_pr_branch(pr_num, pick_ref)
+ owner = _fork_owner()
Review Comment:
**Nit (P3):** The checks at 1593-1601 run only after the pick, any manual
conflict resolution, the binding-policy prompt and the optional compile. Every
failure from there ends the whole run:
- `fail()` on `FORK_REMOTE_NAME == PUSH_REMOTE_NAME`;
- `fail()` from `_fork_owner` on a URL it can't parse, such as an SSH host
alias like `git@github-work:owner/spark.git`;
- an uncaught `CalledProcessError` from `git remote get-url` when the remote
doesn't exist (e.g. the fork remote is named `fork` and `FORK_REMOTE_NAME` is
left at `origin`);
- `fail()` on a rejected fork push;
- a `URLError` from `create_pr`, which only catches `HTTPError`.
Both `fail()` and the top-level `except BaseException` run `clean_up()`,
which runs `git branch -D` on the `PR_TOOL_PICK` branch holding the unpushed
pick, and the remaining branches are never offered. The PR's own comment at
1168-1170 names this exact harm for the compile check.
Suggested fix: check the fork target before anything is pushed. On any of
these failures, return to the landing prompt without `[p]`, so the committer
can still push directly or skip while the run continues. Once the fork push has
succeeded, handle any PR-creation error the way the existing HTTP-error path
does: record the `fork:` outcome and print the manual-PR hint.
**Verification:**
- **Regression:** With a missing fork remote, an unparsable fork URL or a
failing fork push, choosing the backport-PR route leaves the pick available for
a direct push or a skip, and the remaining branch prompts still run.
- **Behavior:** A URLError from PR creation after a successful fork push
records the fork-only outcome and prints the manual-PR instruction.
##########
dev/merge_spark_pr.py:
##########
@@ -111,6 +119,32 @@
SKIP_VERSION_CHECK = os.environ.get("SKIP_VERSION_CHECK", "")
# Path of this script relative to the repo root, used to fetch the canonical
copy from master.
MERGE_SCRIPT_REPO_PATH = "dev/merge_spark_pr.py"
+# Optional pre-push check offered by maybe_compile_check: a clean SBT compile
of the main and
+# test sources run on the merged tree, i.e. exactly what would be pushed. Slow
on a cold build,
+# but catches mis-resolved conflicts and PRs that bit-rotted after their CI
ran. `clean` deletes
+# every module's build output, so the offer says so -- declining is a
reasonable answer.
+COMPILE_CHECK_CMD = ["build/sbt"] + [";clean;compile;Test/compile"]
Review Comment:
**Non-blocking (P2):** With no `-P` argument, `build/sbt` never exports
`SBT_MAVEN_PROFILES` (`enableProfile` in `build/sbt-launch-lib.bash` is reached
only from the `-P*)` case). SparkBuild then defaults to `Seq("sbt")`
(`project/SparkBuild.scala:142-148`), and the `sbt` profile in pom.xml adds
guava and pmml-model at compile scope, while CI's Build Spark step always
passes explicit `-P` flags. So with `SBT_MAVEN_PROFILES` unset (the usual
case), this check runs in exactly the configuration the PR description says
turns 'a CI failure into a local pass'. For example, a backport that adds a
`com.google.common` import to a module without its own guava dependency
compiles green here and fails CI. The doctest at 1066-1067 can't see this,
because it only checks that the literal `-Psbt` is absent from argv.
Narrowing to a smoke test was your call (211f912d511, a23a43bd1da), and
that's fine. Any explicit `-P` selection, even a short list of non-flaky
profiles, avoids the fallback. The docs need to match too: 'with the CI
profiles and no `-Psbt`' (1047-1048), 'covers everything' (1195-1196) and
'exactly what would be pushed' (123; it is the working tree that gets compiled,
not the commit) no longer hold.
**Verification:**
- **Behavior:** The compile-check command carries an explicit Maven profile
selection, so SparkBuild's sbt default is not used when SBT_MAVEN_PROFILES is
unset.
- **Inspection:** The compile-check docstring, the command comment and the
binding-policy docstring describe the actual module coverage and make no
CI-parity or complete-coverage claim.
##########
dev/merge_spark_pr.py:
##########
@@ -946,9 +1569,68 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
else:
print("Cherry-pick already completed manually; continuing with the
backport.")
- continue_maybe(
- "Pick complete (local ref %s). Push to %s?" % (pick_branch_name,
PUSH_REMOTE_NAME)
+ maybe_binding_policy_warning(pick_ref, pick_head, cherry=True)
+ maybe_compile_check(pick_branch_name, cherry=True)
+
+ choice = get_input(
+ "Pick complete (local ref %s). How should it land?\n"
+ " [d] Push directly to %s/%s\n"
+ " [p] Push to your fork (%s) and open a draft backport PR against %s
(for CI)\n"
+ " [s] Skip this branch (default)\n"
+ "Choose [d/p/s]: "
+ % (pick_branch_name, PUSH_REMOTE_NAME, pick_ref, FORK_REMOTE_NAME,
pick_ref),
+ {"d": ["d", "direct"], "p": ["p", "pr"], "s": ["s", "skip", ""]},
)
+ if choice == "s":
+ # Restore the original checkout and drop the temp PICK_PR_* branches
before skipping,
+ # as the continue_maybe decline path does. Otherwise this branch stays
checked out and a
+ # later pick of the same ref fails when `git fetch` tries to update
the checked-out branch.
+ print("Skipping this branch; the merge continues.")
+ clean_up()
+ raise SkipCherryPick()
+
+ if choice == "p":
+ if FORK_REMOTE_NAME == PUSH_REMOTE_NAME:
+ fail(
+ "FORK_REMOTE_NAME (%s) must differ from PUSH_REMOTE_NAME (%s);
otherwise "
+ "the backport-PR path pushes straight to the Apache remote.
Set "
+ "FORK_REMOTE_NAME to your fork remote and retry."
+ % (FORK_REMOTE_NAME, PUSH_REMOTE_NAME)
+ )
+ backport_branch = _backport_pr_branch(pr_num, pick_ref)
+ owner = _fork_owner()
+ try:
+ git.push(FORK_REMOTE_NAME, pick_branch_name, backport_branch)
+ except Exception as e:
+ fail("Exception while pushing to fork: %s" % e)
+ head = "%s:%s" % (owner, backport_branch)
+ # Reuse the backport commit's own message as the PR description -- the
same body GitHub
+ # would prefill from this single-commit branch -- rather than a bare
one-liner, and tag
+ # the title with the target branch. The @ symbols were stripped when
the merge commit
+ # was built, so this does not fire mention mails; fall back to a short
descriptor only
+ # if the commit somehow carries no body.
+ pr_title = _backport_pr_title(pick_ref, title)
+ pr_body = git.run(["git", "log", "-1", "--format=%b",
pick_branch_name]).strip()
+ if not pr_body:
+ pr_body = "Backport of #%s to `%s`." % (pr_num, pick_ref)
+ created = github.create_pr(pick_ref, head, pr_title, pr_body)
+ pick_hash = git.run("git rev-parse %s" % pick_branch_name).strip()
+ clean_up()
+ if created and created.get("html_url"):
+ pr_url = created["html_url"]
+ print("Backport PR opened: %s" % pr_url)
+ print("Pull request #%s backported to %s via %s" % (pr_num,
pick_ref, pr_url))
+ return pick_ref, pr_url
+ # PR creation failed (no token / HTTP error); the cherry-pick is on
the fork, not on
+ # the Apache branch, so return a `fork:` marker (not a bare hash) so
the merge comment
+ # and the JIRA fix-version logic both treat this branch as
not-yet-landed.
+ print_error(
+ "Backport PR was not opened (see above). The cherry-pick is on
%s:%s; open the "
+ "PR manually against %s." % (FORK_REMOTE_NAME, backport_branch,
pick_ref)
+ )
+ print("Pull request #%s picked into %s (PR open pending)!" % (pr_num,
pick_ref))
+ print("Pick hash: %s" % pick_hash)
+ return pick_ref, "fork:%s:%s" % (owner, backport_branch)
Review Comment:
**Nit (P3):** The startup doctests run on every invocation, but none of them
reaches this branch. If this return gave `(pick_ref, pick_hash)` instead of the
`fork:` marker, every doctest would still pass. Meanwhile `_landed_pick_refs`
would count the fork-only branch as landed, JIRA would get a fix version for a
branch that never received the change, and the merge comment would link a
non-existent apache/spark commit. The same is true if `DryRunGitHub.create_pr`
were removed and a dry run reached the real API. The environment-driven modes
are uncovered too: no `GITHUB_OAUTH_KEY`, `DRY_RUN`, `FORK_OWNER` precedence,
and the fork==push guard.
Mocked doctests in the style already used for `maybe_compile_check` would
cover them: Enter mapping to skip, success returning the PR URL, no-token or
failed creation returning `fork:`, the dry-run stub, and `FORK_OWNER`.
**Verification:**
- **Regression:** Each listed regression (the fallback returning a hash, the
dry-run stub removed, FORK_OWNER ignored, Enter mapped to a direct push) causes
a test failure.
- **Behavior:** The success, no-token fallback and dry-run cases each assert
the recorded outcome, and that no real push or PR creation happens.
##########
dev/merge_spark_pr.py:
##########
@@ -828,13 +1007,395 @@ def clean_up():
git.run("git branch -D %s" % branch)
+def maybe_compile_check(ref_name: str, cherry: bool = False) -> None:
+ """Offer to run a clean SBT compile of main and test sources before
pushing `ref_name`.
+
+ `ref_name` is checked out, so the working tree is the merge (or
cherry-pick) about to be
+ pushed and a clean compile here catches breakage -- a mis-resolved
conflict, or a PR that
+ bit-rotted against the target branch after its CI ran -- before it lands
on the Apache
+ repo. Declining skips the check. A build that does not come back green
does not abort on
+ its own: it asks, because a non-zero sbt is as easily a JDK the branch
rejects or a failed
+ download as it is the backport, and `cherry` decides whether declining
skips this branch or
+ exits (see continue_maybe). The build's output streams to the terminal so
a long compile
+ does not look like a hang.
+
+ What is compiled is the working tree, not the commit: a file re-edited
after the merge
+ commit, or an untracked source file left over from a manual conflict
resolution, compiles
+ here and is absent from the push. (A resolved-but-never-`git add`ed
conflict is NOT the
+ case -- the commit itself fails while files are unmerged, so the check is
never reached.)
+ So warn when the tree is dirty rather than report a green that is about
something else,
+ and name the remedy (`git add` + `git commit --amend`) since the fix is
the commit, not
+ the build.
+
+ Declining runs no build and no git -- the git.run mock raises if a
regression moves a
+ git call above the prompt, which otherwise would run real git in the
committer's repo on
+ every startup (doctest.testmod() runs on invocation):
+
+ >>> from contextlib import redirect_stdout
+ >>> from io import StringIO
+ >>> from unittest.mock import call, patch
+ >>> with (
+ ... patch("builtins.input", return_value=""),
+ ... patch.object(git, "run", side_effect=AssertionError("should not
run git")),
+ ... patch("subprocess.call") as run,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> run.call_count
+ 0
+
+ Accepting runs the SBT compile once, from the repo root, with the CI
profiles and no
+ `-Psbt`; a green build returns quietly. The two git calls are pinned too
-- transposing
+ them or dropping --porcelain would otherwise stay green:
+
+ >>> with (
+ ... patch("builtins.input", return_value="y"),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]) as grun,
+ ... patch("subprocess.call", return_value=0) as run,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> grun.call_args_list[0]
+ call(['git', 'rev-parse', '--show-toplevel'])
+ >>> grun.call_args_list[1]
+ call(['git', 'status', '--porcelain'])
+ >>> run.call_count
+ 1
+ >>> run.call_args[0][0][0], run.call_args[0][0][-1]
+ ('build/sbt', ';clean;compile;Test/compile')
+ >>> "-Psbt" in run.call_args[0][0]
+ False
+ >>> run.call_args[1]["cwd"]
+ '/repo'
+
+ A dirty tree still builds, but warns first -- and the warning names the
remedy
+ (`git add` + `git commit --amend`) alongside the porcelain output:
+
+ >>> with (
+ ... patch("builtins.input", return_value="y"),
+ ... patch.object(git, "run", side_effect=["/repo\\n", " M
foo.scala\\n"]),
+ ... patch("subprocess.call", return_value=0),
+ ... redirect_stdout(StringIO()) as out,
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> "git commit --amend" in out.getvalue()
+ True
+ >>> "M foo.scala" in out.getvalue() # porcelain output is strip()ed
before printing
+ True
+ >>> "Compile check passed." in out.getvalue()
+ True
+
+ A red build aborts the merge (SystemExit from fail) when the override is
declined:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", ""]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ ... aborted = False
+ ... except SystemExit:
+ ... aborted = True
+ >>> aborted
+ True
+
+ The experts-only override continues past a red build:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", "y"]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+
+ Ctrl-C during the build, and a build/sbt that cannot be run at all, are
"did not pass" and
+ get the same override prompt -- never a traceback, which would reach the
top-level handler
+ and let clean_up() delete the unpushed merge commit:
+
+ >>> for boom in (KeyboardInterrupt(), OSError("no build/sbt")):
+ ... with (
+ ... patch("builtins.input", side_effect=["y", "y"]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", side_effect=boom),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+
+ In a cherry-pick, declining skips just that branch instead of exiting:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", ""]),
+ ... patch.object(
+ ... git, "run", side_effect=["/repo\\n", "",
subprocess.CalledProcessError(1, "g")]
+ ... ),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_compile_check("PR_TOOL_PICK_PR_1_BRANCH-4.1",
cherry=True)
+ ... skipped = False
+ ... except SkipCherryPick:
+ ... skipped = True
+ >>> skipped
+ True
+ """
+ # The command is too long to read inside a prompt, so print it on its own
line first.
+ # shlex.join quotes the ";clean;..." argument, so the printed line can be
copy-pasted
+ # into a shell without the semicolons splitting it into four commands.
+ print("Optional pre-push check: %s" % shlex.join(COMPILE_CHECK_CMD))
+ print("Note: 'clean' deletes every module's build output, including under
--dry-run.")
+ prompt = "Run an optional clean compile on %s before pushing? (y/N): " %
ref_name
+ if get_input(prompt, ["y", "n", ""]) != "y":
+ return
+ # build/sbt is repo-relative and SPARK_HOME defaults to the cwd, which is
dev/ when the
+ # script is run as ./merge_spark_pr.py per its own usage line. Ask git for
the root instead.
+ root = git.run(["git", "rev-parse", "--show-toplevel"]).strip()
+ dirty = git.run(["git", "status", "--porcelain"]).strip()
+ if dirty:
+ print_error(
+ "Working tree is not clean, so what compiles below is not exactly
the commit that "
+ "would be pushed -- the uncommitted or untracked changes below
compile here but "
+ "are not in the push. If any of them belong in the merge, 'git
add' them and "
+ "'git commit --amend' before pushing; if they are unrelated
scratch, ignore this "
+ "and carry on:\n%s" % dirty
+ )
+ try:
+ status = subprocess.call(COMPILE_CHECK_CMD, cwd=root)
+ except KeyboardInterrupt:
+ # Interrupting the build must not unwind into the top-level handler:
clean_up() there
+ # force-deletes the PR_TOOL_* branch holding the unpushed commit and
any hand-resolved
+ # conflicts. Treat it as "the check did not finish" and let the
committer decide.
+ print_error("Compile check interrupted.")
+ except OSError as e:
+ print_error("Could not run %s in %s: %s" % (COMPILE_CHECK_CMD[0],
root, e))
+ else:
+ if status == 0:
+ print("Compile check passed.")
+ return
+ print_error(
+ "Compile check exited %d. That is the backport, or your toolchain
-- a JDK this "
+ "branch rejects and a failed dependency download look the same
here." % status
+ )
+ continue_maybe(
+ "Compile check did not pass on %s. Push anyway? (experts only!)" %
ref_name, cherry
+ )
+
+
+def _is_pre_binding_policy_branch(ref: str) -> bool:
+ """True if `ref` is a Spark branch that predates the ConfigBindingPolicy
API (4.2+).
+
+ master and branch-M.x (M >= 4) carry the API; branch-4.0/branch-4.1 and
any branch-3.x
+ do not. Ranking is semver_branch_rank's job -- branch-M.x sorts above
every branch-M.N,
+ and a ref it cannot classify as a maintenance branch gets its (-1, -1)
sentinel. Those
+ refs are treated as carrying the API: fail-open, so master, a tag, or a
custom ref never
+ raises a false warning. The committer sees nothing in that case, which is
the right trade:
+ a backport additionally gets maybe_compile_check right after this -- the
check that covers
+ everything rather than one known API -- and the fail-open refs on a direct
merge (master,
+ a tag, a custom ref) are not pre-4.2 branches in the first place.
+
+ >>> _is_pre_binding_policy_branch("master")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.x")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.2")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.1")
+ True
+ >>> _is_pre_binding_policy_branch("HEAD")
+ False
+ >>> _is_pre_binding_policy_branch("my-custom-ref")
+ False
+ """
+ rank = semver_branch_rank(ref)
+ return rank != (-1, -1) and rank < (4, 2)
+
+
+def _added_binding_policy_tokens(diff: str) -> list[str]:
+ """Return the sorted subset of BINDING_POLICY_PATTERNS matched on added
(``+``) lines.
+
+ Only scans lines the diff added (a leading ``+`` that is not the ``+++ ``
file header --
+ the trailing space keeps an added line whose own text starts with ``++``
counted, apart
+ from the ``++ <space>`` case no real Scala/Java line starts with), so a
cherry-pick that
+ *removes* a stray .withBindingPolicy is not flagged. Matching uses the
+ precise patterns from BINDING_POLICY_PATTERNS, so a comment that merely
mentions
+ ``withBindingPolicy`` (no leading dot, no call) is not flagged;
``ConfigBindingPolicy`` in
+ a comment still can match, which is fine -- the prompt is advisory.
+
+ >>> _added_binding_policy_tokens(
+ ... "+++ b/foo\\n"
+ ... "+.withBindingPolicy(X)\\n"
+ ... "-.withBindingPolicy(Y)\\n"
+ ... "+import ...ConfigBindingPolicy\\n"
+ ... "+// see withBindingPolicy usage\\n"
+ ... " context\\n"
+ ... )
+ ['ConfigBindingPolicy', 'withBindingPolicy']
+ >>> _added_binding_policy_tokens("-.withBindingPolicy(X)\\n")
+ []
+ >>> _added_binding_policy_tokens("+// mentions ConfigBindingPolicy in a
comment\\n")
+ ['ConfigBindingPolicy']
+ >>> _added_binding_policy_tokens("")
+ []
+ """
+ found = set()
+ for line in diff.splitlines():
+ if line.startswith("+") and not line.startswith("+++ "):
+ for token, pattern in BINDING_POLICY_PATTERNS:
+ if pattern.search(line):
+ found.add(token)
+ return sorted(found)
+
+
+def maybe_binding_policy_warning(ref: str, base_head: str, cherry: bool =
False) -> None:
+ """Warn before pushing to a pre-4.2 `ref` a change that adds
ConfigBindingPolicy.
+
+ A merge or cherry-pick onto branch-4.1/4.0/3.x that introduces
``.withBindingPolicy(...)``
+ or ``ConfigBindingPolicy`` will not compile there (the API arrived in
SPARK-55928, 4.2+).
+ This is a git-diff + grep pre-push check: it diffs `base_head` (the branch
tip before the
+ merge or pick) against HEAD restricted to Scala/Java sources, scans only
the added lines
+ for the precise patterns in BINDING_POLICY_PATTERNS, and on a hit prints
the remedy and
+ interactively asks the committer to confirm the push. No-op on branches
that carry the
+ API, so master/branch-4.x are unaffected. `cherry` decides whether
declining skips just
+ this branch or exits (see continue_maybe).
+
+ The diff is forced plain: without --no-color a committer with ``color.ui =
always`` gets
+ added lines behind an ANSI escape, no line starts with "+", and the check
reports nothing
+ while looking like it ran. Like branches_with_merge_footer, a git failure
only warns: on
+ the cherry-pick path this runs after master has already been pushed, and
on the
+ direct-merge path a broken grep must not be the thing that blocks a good
merge either.
+
+ `never` below is how these tests assert "does not prompt": a bare input
mock would return a
+ MagicMock that get_input's loop never accepts, so a regressed guard would
hang the script at
+ startup -- doctest.testmod() runs on every invocation -- instead of
failing.
+
+ >>> from contextlib import redirect_stdout
+ >>> from io import StringIO
+ >>> from unittest.mock import patch
+ >>> never = AssertionError("should not prompt")
+
+ No-op on a branch that carries the API (no diff fetched, no prompt):
+
+ >>> with (
+ ... patch.object(git, "run") as run,
+ ... patch("builtins.input", side_effect=never),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_binding_policy_warning("branch-4.2", "deadbeef")
+ >>> run.call_count
+ 0
+
+ No-op when the change added none of the tokens. The git command is pinned
here because
+ nothing else in these tests would notice it being wrong -- transposing the
two endpoints
+ inverts the whole check, and dropping the pathspec or --no-color silently
defeats it:
+
+ >>> with (
+ ... patch.object(git, "run", return_value="+ unrelated change\\n") as
run,
+ ... patch("builtins.input", side_effect=never),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_binding_policy_warning("branch-4.1", "deadbeef")
+ >>> run.call_args[0][0][:6]
+ ['git', 'diff', '--no-color', '--no-ext-diff', 'deadbeef', 'HEAD']
+ >>> run.call_args[0][0][6:]
+ ['--', ':(top)*.scala', ':(top)*.java']
+
+ Warns and prompts when the change adds a token on a pre-4.2 branch;
accepting continues
+ (no abort), so input is called once by the continue_maybe prompt:
+
+ >>> with (
+ ... patch.object(git, "run", return_value="+.withBindingPolicy(X)\\n"),
+ ... patch("builtins.input", return_value="y") as inp,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_binding_policy_warning("branch-4.1", "deadbeef")
+ >>> inp.call_count
+ 1
+
+ Declining aborts the merge (SystemExit from fail), matching the
compile-check override:
+
+ >>> with (
+ ... patch.object(git, "run", return_value="+.withBindingPolicy(X)\\n"),
+ ... patch("builtins.input", return_value=""),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_binding_policy_warning("branch-4.1", "deadbeef")
+ ... aborted = False
+ ... except SystemExit:
+ ... aborted = True
+ >>> aborted
+ True
+
+ In a cherry-pick, declining skips just that branch, so an already-pushed
branch-M.x pick
+ and the JIRA resolution survive (the second git.run is the
CHERRY_PICK_HEAD probe):
+
+ >>> with (
+ ... patch.object(
+ ... git,
+ ... "run",
+ ... side_effect=[
+ ... "+.withBindingPolicy(X)\\n",
+ ... subprocess.CalledProcessError(1, "git"),
+ ... ],
+ ... ),
+ ... patch("builtins.input", return_value=""),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_binding_policy_warning("branch-4.1", "deadbeef",
cherry=True)
+ ... skipped = False
+ ... except SkipCherryPick:
+ ... skipped = True
+ >>> skipped
+ True
+
+ A git failure warns and scans nothing rather than aborting a half-pushed
merge:
+
+ >>> with (
+ ... patch.object(git, "run", side_effect=OSError("git is sad")),
+ ... patch("builtins.input", side_effect=never),
+ ... redirect_stdout(StringIO()) as out,
+ ... ):
+ ... maybe_binding_policy_warning("branch-4.1", "deadbeef")
+ >>> "git is sad" in out.getvalue()
+ True
+ """
+ if not _is_pre_binding_policy_branch(ref):
+ return
+ try:
+ diff = git.run(
+ ["git", "diff", "--no-color", "--no-ext-diff", base_head, "HEAD",
"--"]
+ + list(BINDING_POLICY_PATHSPECS)
+ )
+ except Exception as e:
+ print_error(
+ "Could not diff %s..HEAD; skipping the ConfigBindingPolicy check:
%s" % (base_head, e)
+ )
+ return
+ tokens = _added_binding_policy_tokens(diff)
+ if not tokens:
+ return
+ print_error(
+ "Warning: what would be pushed to %s adds %s, which does not exist
before branch-4.2 "
+ "(SPARK-55928). Drop the .withBindingPolicy(...) call and the
ConfigBindingPolicy "
+ "import. Leave .version(...) alone -- it names the release the config
shipped in -- "
+ "unless the config itself is new on this branch, in which case
retarget it to this "
+ "branch's next release." % (ref, " / ".join(tokens))
+ )
+ continue_maybe("Push to %s anyway? (experts only!)" % ref, cherry)
+
+
# merge the requested PR and return the merge hash
def merge_pr(pr_num, target_ref, title, body, pr_repo_desc, pr_author,
co_authors):
pr_branch_name = "%s_MERGE_PR_%s" % (BRANCH_PREFIX, pr_num)
target_branch_name = "%s_MERGE_PR_%s_%s" % (BRANCH_PREFIX, pr_num,
target_ref.upper())
git.run("git fetch %s pull/%s/head:%s" % (PR_REMOTE_NAME, pr_num,
pr_branch_name))
git.run("git fetch %s %s:%s" % (PUSH_REMOTE_NAME, target_ref,
target_branch_name))
git.run("git checkout %s" % target_branch_name)
+ target_head = git.run("git rev-parse HEAD").strip()
Review Comment:
**Nit (P3):** Nothing reads `target_head` after this line. Its only
consumer, `maybe_binding_policy_warning(target_ref, target_head)`, was dropped
in 702d029344e, and `merge_pr` has no other use of it. Because `Git.run` echoes
every command, each merge now prints a stray `git rev-parse HEAD` and spawns a
process for nothing. It can go together with the merge-path wording that still
describes the removed check.
See **Shared repair plan 3** in the review body.
##########
dev/merge_spark_pr.py:
##########
@@ -946,9 +1569,68 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
else:
print("Cherry-pick already completed manually; continuing with the
backport.")
- continue_maybe(
- "Pick complete (local ref %s). Push to %s?" % (pick_branch_name,
PUSH_REMOTE_NAME)
+ maybe_binding_policy_warning(pick_ref, pick_head, cherry=True)
+ maybe_compile_check(pick_branch_name, cherry=True)
+
+ choice = get_input(
+ "Pick complete (local ref %s). How should it land?\n"
+ " [d] Push directly to %s/%s\n"
+ " [p] Push to your fork (%s) and open a draft backport PR against %s
(for CI)\n"
+ " [s] Skip this branch (default)\n"
+ "Choose [d/p/s]: "
+ % (pick_branch_name, PUSH_REMOTE_NAME, pick_ref, FORK_REMOTE_NAME,
pick_ref),
+ {"d": ["d", "direct"], "p": ["p", "pr"], "s": ["s", "skip", ""]},
)
+ if choice == "s":
+ # Restore the original checkout and drop the temp PICK_PR_* branches
before skipping,
+ # as the continue_maybe decline path does. Otherwise this branch stays
checked out and a
+ # later pick of the same ref fails when `git fetch` tries to update
the checked-out branch.
+ print("Skipping this branch; the merge continues.")
+ clean_up()
+ raise SkipCherryPick()
+
+ if choice == "p":
+ if FORK_REMOTE_NAME == PUSH_REMOTE_NAME:
+ fail(
+ "FORK_REMOTE_NAME (%s) must differ from PUSH_REMOTE_NAME (%s);
otherwise "
+ "the backport-PR path pushes straight to the Apache remote.
Set "
+ "FORK_REMOTE_NAME to your fork remote and retry."
+ % (FORK_REMOTE_NAME, PUSH_REMOTE_NAME)
+ )
+ backport_branch = _backport_pr_branch(pr_num, pick_ref)
+ owner = _fork_owner()
+ try:
+ git.push(FORK_REMOTE_NAME, pick_branch_name, backport_branch)
Review Comment:
**Nit (P3):** `_backport_pr_branch` is deterministic, and `Git.push` is a
plain `git push <remote> <local>:<remote>`. A re-pick produces a new commit
(new committer timestamp, and possibly a moved base) that doesn't contain the
earlier fork commit. The second push is therefore non-fast-forward and goes to
`fail('Exception while pushing to fork: ...')`, which deletes the pick. A later
run re-offers the branch, because `branches_with_merge_footer` scans only
`PUSH_REMOTE_NAME`. The same failure hits a retry after a first attempt without
`GITHUB_OAUTH_KEY`, since `create_pr` checks the token only after the fork push
has already succeeded.
Suggested fix: once the fork target's identity is validated (not before),
treat this branch as script-owned and update it with `--force-with-lease`. Also
reuse an existing backport PR for the same head instead of creating a new one.
**Verification:**
- **Regression:** A second backport-PR attempt for the same PR and branch,
after a first attempt left the fork branch behind, updates that branch and ends
with a PR link or the fork-only outcome rather than a non-fast-forward failure.
##########
dev/merge_spark_pr.py:
##########
@@ -828,13 +1007,395 @@ def clean_up():
git.run("git branch -D %s" % branch)
+def maybe_compile_check(ref_name: str, cherry: bool = False) -> None:
+ """Offer to run a clean SBT compile of main and test sources before
pushing `ref_name`.
+
+ `ref_name` is checked out, so the working tree is the merge (or
cherry-pick) about to be
+ pushed and a clean compile here catches breakage -- a mis-resolved
conflict, or a PR that
+ bit-rotted against the target branch after its CI ran -- before it lands
on the Apache
+ repo. Declining skips the check. A build that does not come back green
does not abort on
+ its own: it asks, because a non-zero sbt is as easily a JDK the branch
rejects or a failed
+ download as it is the backport, and `cherry` decides whether declining
skips this branch or
+ exits (see continue_maybe). The build's output streams to the terminal so
a long compile
+ does not look like a hang.
+
+ What is compiled is the working tree, not the commit: a file re-edited
after the merge
+ commit, or an untracked source file left over from a manual conflict
resolution, compiles
+ here and is absent from the push. (A resolved-but-never-`git add`ed
conflict is NOT the
+ case -- the commit itself fails while files are unmerged, so the check is
never reached.)
+ So warn when the tree is dirty rather than report a green that is about
something else,
+ and name the remedy (`git add` + `git commit --amend`) since the fix is
the commit, not
+ the build.
+
+ Declining runs no build and no git -- the git.run mock raises if a
regression moves a
+ git call above the prompt, which otherwise would run real git in the
committer's repo on
+ every startup (doctest.testmod() runs on invocation):
+
+ >>> from contextlib import redirect_stdout
+ >>> from io import StringIO
+ >>> from unittest.mock import call, patch
+ >>> with (
+ ... patch("builtins.input", return_value=""),
+ ... patch.object(git, "run", side_effect=AssertionError("should not
run git")),
+ ... patch("subprocess.call") as run,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> run.call_count
+ 0
+
+ Accepting runs the SBT compile once, from the repo root, with the CI
profiles and no
+ `-Psbt`; a green build returns quietly. The two git calls are pinned too
-- transposing
+ them or dropping --porcelain would otherwise stay green:
+
+ >>> with (
+ ... patch("builtins.input", return_value="y"),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]) as grun,
+ ... patch("subprocess.call", return_value=0) as run,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> grun.call_args_list[0]
+ call(['git', 'rev-parse', '--show-toplevel'])
+ >>> grun.call_args_list[1]
+ call(['git', 'status', '--porcelain'])
+ >>> run.call_count
+ 1
+ >>> run.call_args[0][0][0], run.call_args[0][0][-1]
+ ('build/sbt', ';clean;compile;Test/compile')
+ >>> "-Psbt" in run.call_args[0][0]
+ False
+ >>> run.call_args[1]["cwd"]
+ '/repo'
+
+ A dirty tree still builds, but warns first -- and the warning names the
remedy
+ (`git add` + `git commit --amend`) alongside the porcelain output:
+
+ >>> with (
+ ... patch("builtins.input", return_value="y"),
+ ... patch.object(git, "run", side_effect=["/repo\\n", " M
foo.scala\\n"]),
+ ... patch("subprocess.call", return_value=0),
+ ... redirect_stdout(StringIO()) as out,
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> "git commit --amend" in out.getvalue()
+ True
+ >>> "M foo.scala" in out.getvalue() # porcelain output is strip()ed
before printing
+ True
+ >>> "Compile check passed." in out.getvalue()
+ True
+
+ A red build aborts the merge (SystemExit from fail) when the override is
declined:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", ""]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ ... aborted = False
+ ... except SystemExit:
+ ... aborted = True
+ >>> aborted
+ True
+
+ The experts-only override continues past a red build:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", "y"]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+
+ Ctrl-C during the build, and a build/sbt that cannot be run at all, are
"did not pass" and
+ get the same override prompt -- never a traceback, which would reach the
top-level handler
+ and let clean_up() delete the unpushed merge commit:
+
+ >>> for boom in (KeyboardInterrupt(), OSError("no build/sbt")):
+ ... with (
+ ... patch("builtins.input", side_effect=["y", "y"]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", side_effect=boom),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+
+ In a cherry-pick, declining skips just that branch instead of exiting:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", ""]),
+ ... patch.object(
+ ... git, "run", side_effect=["/repo\\n", "",
subprocess.CalledProcessError(1, "g")]
+ ... ),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_compile_check("PR_TOOL_PICK_PR_1_BRANCH-4.1",
cherry=True)
+ ... skipped = False
+ ... except SkipCherryPick:
+ ... skipped = True
+ >>> skipped
+ True
+ """
+ # The command is too long to read inside a prompt, so print it on its own
line first.
+ # shlex.join quotes the ";clean;..." argument, so the printed line can be
copy-pasted
+ # into a shell without the semicolons splitting it into four commands.
+ print("Optional pre-push check: %s" % shlex.join(COMPILE_CHECK_CMD))
+ print("Note: 'clean' deletes every module's build output, including under
--dry-run.")
+ prompt = "Run an optional clean compile on %s before pushing? (y/N): " %
ref_name
+ if get_input(prompt, ["y", "n", ""]) != "y":
+ return
+ # build/sbt is repo-relative and SPARK_HOME defaults to the cwd, which is
dev/ when the
+ # script is run as ./merge_spark_pr.py per its own usage line. Ask git for
the root instead.
+ root = git.run(["git", "rev-parse", "--show-toplevel"]).strip()
+ dirty = git.run(["git", "status", "--porcelain"]).strip()
+ if dirty:
+ print_error(
+ "Working tree is not clean, so what compiles below is not exactly
the commit that "
+ "would be pushed -- the uncommitted or untracked changes below
compile here but "
+ "are not in the push. If any of them belong in the merge, 'git
add' them and "
+ "'git commit --amend' before pushing; if they are unrelated
scratch, ignore this "
+ "and carry on:\n%s" % dirty
+ )
+ try:
+ status = subprocess.call(COMPILE_CHECK_CMD, cwd=root)
+ except KeyboardInterrupt:
+ # Interrupting the build must not unwind into the top-level handler:
clean_up() there
+ # force-deletes the PR_TOOL_* branch holding the unpushed commit and
any hand-resolved
+ # conflicts. Treat it as "the check did not finish" and let the
committer decide.
+ print_error("Compile check interrupted.")
+ except OSError as e:
+ print_error("Could not run %s in %s: %s" % (COMPILE_CHECK_CMD[0],
root, e))
+ else:
+ if status == 0:
+ print("Compile check passed.")
+ return
+ print_error(
+ "Compile check exited %d. That is the backport, or your toolchain
-- a JDK this "
+ "branch rejects and a failed dependency download look the same
here." % status
+ )
+ continue_maybe(
+ "Compile check did not pass on %s. Push anyway? (experts only!)" %
ref_name, cherry
+ )
+
+
+def _is_pre_binding_policy_branch(ref: str) -> bool:
+ """True if `ref` is a Spark branch that predates the ConfigBindingPolicy
API (4.2+).
+
+ master and branch-M.x (M >= 4) carry the API; branch-4.0/branch-4.1 and
any branch-3.x
+ do not. Ranking is semver_branch_rank's job -- branch-M.x sorts above
every branch-M.N,
+ and a ref it cannot classify as a maintenance branch gets its (-1, -1)
sentinel. Those
+ refs are treated as carrying the API: fail-open, so master, a tag, or a
custom ref never
+ raises a false warning. The committer sees nothing in that case, which is
the right trade:
+ a backport additionally gets maybe_compile_check right after this -- the
check that covers
+ everything rather than one known API -- and the fail-open refs on a direct
merge (master,
+ a tag, a custom ref) are not pre-4.2 branches in the first place.
+
+ >>> _is_pre_binding_policy_branch("master")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.x")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.2")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.1")
Review Comment:
**Nit (P3):** `branch-4.1` is now the only pre-4.2 case, although the
docstring promises branch-4.0, branch-4.1 and any branch-3.x. The classifier is
right today: I ran it, and branch-3.5, branch-3.x (rank `(3, inf)`) and
branch-4.0 all return `True`. But rewriting `rank < (4, 2)` as `rank[0] == 4
and rank[1] < 2` would keep every remaining doctest green while branch-3.x
backports stop being warned. Restoring `branch-3.5`, `branch-3.x` and
`branch-4.0` next to `branch-4.1` takes three lines.
**Verification:**
- **Regression:** Branch classification is asserted pre-4.2 for branch-3.5,
branch-3.x and branch-4.0 as well as branch-4.1, so a comparison that checks
only the minor version fails.
##########
dev/merge_spark_pr.py:
##########
@@ -901,12 +1462,74 @@ def merge_pr(pr_num, target_ref, title, body,
pr_repo_desc, pr_author, co_author
return merge_hash
-def _do_cherry_pick(pr_num, merge_hash, pick_ref):
+def _backport_pr_branch(pr_num, pick_ref):
+ """Fork branch name for a backport PR: stable, PR-traceable,
branch-suffixed.
+
+ >>> _backport_pr_branch(123, "branch-4.2")
+ 'backport-pr123-branch-4.2'
+ >>> _backport_pr_branch(9, "branch-3.5")
+ 'backport-pr9-branch-3.5'
+ """
+ return "backport-pr%s-%s" % (pr_num, pick_ref)
+
+
+def _backport_pr_title(pick_ref, title):
+ """PR title for a backport PR: the merged title tagged with its target
branch.
+
+ >>> _backport_pr_title("branch-4.2", "[SPARK-1234][SQL] Fix a thing")
+ '[BACKPORT-branch-4.2] [SPARK-1234][SQL] Fix a thing'
+ """
+ return "[BACKPORT-%s] %s" % (pick_ref, title)
+
+
+def _fork_owner():
+ """GitHub owner of the fork remote, for the `head` field of a backport PR.
+
+ FORK_OWNER wins when set; otherwise the owner is parsed from the fork
remote URL
+ ([email protected]:owner/spark.git or https://github.com/owner/spark.git). A
non-GitHub
+ remote fails with a clear message, rather than silently sending a bad
`head` to the
+ API, so the committer can set FORK_OWNER.
+ """
+ if FORK_OWNER:
+ return FORK_OWNER
+ url = git.run(["git", "remote", "get-url", FORK_REMOTE_NAME]).strip()
+ m = re.search(r"github\.com[:/]([^/]+)/", url)
+ if not m:
+ fail(
+ "Could not parse a GitHub owner from %s remote URL '%s'. "
+ "Set FORK_OWNER to your GitHub login and retry." %
(FORK_REMOTE_NAME, url)
+ )
+ return m.group(1)
+
+
+def _landed_pick_refs(picks):
+ """Refs from `picks` whose change has actually landed on the Apache branch.
+
+ A direct push returns a bare commit hash (landed); the backport-PR paths
return a PR URL
+ (`https://`) or a `fork:` marker (PR not opened), neither of which has
landed yet. Used to
+ keep JIRA fix versions tied to landed commits rather than open backport
PRs.
+
+ >>> _landed_pick_refs([("master", "abc123"), ("branch-4.2",
"https://github.com/apache/spark/pull/9")])
+ ['master']
+ >>> _landed_pick_refs(
+ ... [("branch-4.x", "deadbeef"), ("branch-4.2",
"fork:o:backport-pr1-branch-4.2")]
+ ... )
+ ['branch-4.x']
+ >>> _landed_pick_refs([])
+ []
+ """
+ return [ref for ref, marker in picks if not marker.startswith(("https://",
"fork:"))]
+
+
+def _do_cherry_pick(pr_num, merge_hash, pick_ref, title=""):
"""Cherry-pick `merge_hash` onto `pick_ref` and push.
- Returns the (pushed ref, pushed commit hash) pair. Raises `SkipCherryPick`
if the
- cherry-pick conflicts and the committer declines to resolve it, after
attempting to
- abort the cherry-pick and restore the working tree.
+ Returns the (pushed ref, marker) pair. The marker is the pushed commit
hash for a
+ direct push, or the backport PR URL when the committer chose to land on a
fork branch
Review Comment:
**Nit (P3):** This contract lists only 'the pushed commit hash ... or the
backport PR URL'. It omits the `fork:<owner>:<branch>` marker returned at 1633,
and as `SkipCherryPick` causes it names only a declined conflict and `[s]`,
leaving out the declined binding-policy and compile-check prompts (1572-1573).
The summary line still says '... and push.' (1525). The same assumption shows
up elsewhere: `cherry_pick`'s docstring says the two-branch list 'holds only
what landed' (1730-1731), and the `[b]oth` comment says branch-M.x is returned
so 'JIRA/comment reflect it' (1766-1767), although `main()` keeps pending picks
out of `landed_refs`. `post_merge_comment` says it records 'every branch the
change landed on' (794-795), and `main()` still says 'Ordered (branch,
commit_hash) pairs' (3380) and 'every branch that successfully received the
change' (3423). Readers, and future consumers, will take every returned pick as
landed, which is exactly the assumption behind the Upstream-First by
pass.
See **Shared repair plan 1** in the review body.
##########
dev/merge_spark_pr.py:
##########
@@ -709,9 +743,28 @@ def comment(self, pr_num, body):
print_error("Failed to comment on PR #%s: HTTP %s %s" % (pr_num,
e.code, e.reason))
return None
+ def create_pr(self, base, head, title, body):
Review Comment:
**Nit (P3):** Adding `create_pr` makes the `GitHub` class docstring false:
it still says 'only the two mutations (closing the PR and posting a comment)
live here' (710-711). The neighbouring lists miss the new effects as well. The
`DRY_RUN_ENV` comment (105-106) and the `Git` docstring (899-900) cover only
the push to `PUSH_REMOTE_NAME`. The `--dry-run` help (3085-3086) and the
runtime banner (3101-3102) list 'GitHub PR close/comment' but not PR creation,
and the banner names only the push to the Apache remote. Suppression itself
works (`DryRunGit.push`, `DryRunGitHub.create_pr`), so the fix is just to
update those five texts to mention fork pushes and draft PR creation.
**Verification:**
- **Inspection:** The seam docstrings, the DRY_RUN_ENV comment, the
--dry-run help and the dry-run banner each name fork pushes and draft PR
creation alongside the existing effects.
##########
dev/merge_spark_pr.py:
##########
@@ -828,13 +1007,395 @@ def clean_up():
git.run("git branch -D %s" % branch)
+def maybe_compile_check(ref_name: str, cherry: bool = False) -> None:
+ """Offer to run a clean SBT compile of main and test sources before
pushing `ref_name`.
+
+ `ref_name` is checked out, so the working tree is the merge (or
cherry-pick) about to be
+ pushed and a clean compile here catches breakage -- a mis-resolved
conflict, or a PR that
+ bit-rotted against the target branch after its CI ran -- before it lands
on the Apache
+ repo. Declining skips the check. A build that does not come back green
does not abort on
+ its own: it asks, because a non-zero sbt is as easily a JDK the branch
rejects or a failed
+ download as it is the backport, and `cherry` decides whether declining
skips this branch or
+ exits (see continue_maybe). The build's output streams to the terminal so
a long compile
+ does not look like a hang.
+
+ What is compiled is the working tree, not the commit: a file re-edited
after the merge
+ commit, or an untracked source file left over from a manual conflict
resolution, compiles
+ here and is absent from the push. (A resolved-but-never-`git add`ed
conflict is NOT the
+ case -- the commit itself fails while files are unmerged, so the check is
never reached.)
+ So warn when the tree is dirty rather than report a green that is about
something else,
+ and name the remedy (`git add` + `git commit --amend`) since the fix is
the commit, not
+ the build.
+
+ Declining runs no build and no git -- the git.run mock raises if a
regression moves a
+ git call above the prompt, which otherwise would run real git in the
committer's repo on
+ every startup (doctest.testmod() runs on invocation):
+
+ >>> from contextlib import redirect_stdout
+ >>> from io import StringIO
+ >>> from unittest.mock import call, patch
+ >>> with (
+ ... patch("builtins.input", return_value=""),
+ ... patch.object(git, "run", side_effect=AssertionError("should not
run git")),
+ ... patch("subprocess.call") as run,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> run.call_count
+ 0
+
+ Accepting runs the SBT compile once, from the repo root, with the CI
profiles and no
+ `-Psbt`; a green build returns quietly. The two git calls are pinned too
-- transposing
+ them or dropping --porcelain would otherwise stay green:
+
+ >>> with (
+ ... patch("builtins.input", return_value="y"),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]) as grun,
+ ... patch("subprocess.call", return_value=0) as run,
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> grun.call_args_list[0]
+ call(['git', 'rev-parse', '--show-toplevel'])
+ >>> grun.call_args_list[1]
+ call(['git', 'status', '--porcelain'])
+ >>> run.call_count
+ 1
+ >>> run.call_args[0][0][0], run.call_args[0][0][-1]
+ ('build/sbt', ';clean;compile;Test/compile')
+ >>> "-Psbt" in run.call_args[0][0]
+ False
+ >>> run.call_args[1]["cwd"]
+ '/repo'
+
+ A dirty tree still builds, but warns first -- and the warning names the
remedy
+ (`git add` + `git commit --amend`) alongside the porcelain output:
+
+ >>> with (
+ ... patch("builtins.input", return_value="y"),
+ ... patch.object(git, "run", side_effect=["/repo\\n", " M
foo.scala\\n"]),
+ ... patch("subprocess.call", return_value=0),
+ ... redirect_stdout(StringIO()) as out,
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ >>> "git commit --amend" in out.getvalue()
+ True
+ >>> "M foo.scala" in out.getvalue() # porcelain output is strip()ed
before printing
+ True
+ >>> "Compile check passed." in out.getvalue()
+ True
+
+ A red build aborts the merge (SystemExit from fail) when the override is
declined:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", ""]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+ ... aborted = False
+ ... except SystemExit:
+ ... aborted = True
+ >>> aborted
+ True
+
+ The experts-only override continues past a red build:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", "y"]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+
+ Ctrl-C during the build, and a build/sbt that cannot be run at all, are
"did not pass" and
+ get the same override prompt -- never a traceback, which would reach the
top-level handler
+ and let clean_up() delete the unpushed merge commit:
+
+ >>> for boom in (KeyboardInterrupt(), OSError("no build/sbt")):
+ ... with (
+ ... patch("builtins.input", side_effect=["y", "y"]),
+ ... patch.object(git, "run", side_effect=["/repo\\n", ""]),
+ ... patch("subprocess.call", side_effect=boom),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... maybe_compile_check("PR_TOOL_MERGE_PR_1_MASTER")
+
+ In a cherry-pick, declining skips just that branch instead of exiting:
+
+ >>> with (
+ ... patch("builtins.input", side_effect=["y", ""]),
+ ... patch.object(
+ ... git, "run", side_effect=["/repo\\n", "",
subprocess.CalledProcessError(1, "g")]
+ ... ),
+ ... patch("subprocess.call", return_value=1),
+ ... redirect_stdout(StringIO()),
+ ... ):
+ ... try:
+ ... maybe_compile_check("PR_TOOL_PICK_PR_1_BRANCH-4.1",
cherry=True)
+ ... skipped = False
+ ... except SkipCherryPick:
+ ... skipped = True
+ >>> skipped
+ True
+ """
+ # The command is too long to read inside a prompt, so print it on its own
line first.
+ # shlex.join quotes the ";clean;..." argument, so the printed line can be
copy-pasted
+ # into a shell without the semicolons splitting it into four commands.
+ print("Optional pre-push check: %s" % shlex.join(COMPILE_CHECK_CMD))
+ print("Note: 'clean' deletes every module's build output, including under
--dry-run.")
+ prompt = "Run an optional clean compile on %s before pushing? (y/N): " %
ref_name
+ if get_input(prompt, ["y", "n", ""]) != "y":
+ return
+ # build/sbt is repo-relative and SPARK_HOME defaults to the cwd, which is
dev/ when the
+ # script is run as ./merge_spark_pr.py per its own usage line. Ask git for
the root instead.
+ root = git.run(["git", "rev-parse", "--show-toplevel"]).strip()
+ dirty = git.run(["git", "status", "--porcelain"]).strip()
+ if dirty:
+ print_error(
+ "Working tree is not clean, so what compiles below is not exactly
the commit that "
+ "would be pushed -- the uncommitted or untracked changes below
compile here but "
+ "are not in the push. If any of them belong in the merge, 'git
add' them and "
+ "'git commit --amend' before pushing; if they are unrelated
scratch, ignore this "
+ "and carry on:\n%s" % dirty
+ )
+ try:
+ status = subprocess.call(COMPILE_CHECK_CMD, cwd=root)
+ except KeyboardInterrupt:
+ # Interrupting the build must not unwind into the top-level handler:
clean_up() there
+ # force-deletes the PR_TOOL_* branch holding the unpushed commit and
any hand-resolved
+ # conflicts. Treat it as "the check did not finish" and let the
committer decide.
+ print_error("Compile check interrupted.")
+ except OSError as e:
+ print_error("Could not run %s in %s: %s" % (COMPILE_CHECK_CMD[0],
root, e))
+ else:
+ if status == 0:
+ print("Compile check passed.")
+ return
+ print_error(
+ "Compile check exited %d. That is the backport, or your toolchain
-- a JDK this "
+ "branch rejects and a failed dependency download look the same
here." % status
+ )
+ continue_maybe(
+ "Compile check did not pass on %s. Push anyway? (experts only!)" %
ref_name, cherry
+ )
+
+
+def _is_pre_binding_policy_branch(ref: str) -> bool:
+ """True if `ref` is a Spark branch that predates the ConfigBindingPolicy
API (4.2+).
+
+ master and branch-M.x (M >= 4) carry the API; branch-4.0/branch-4.1 and
any branch-3.x
+ do not. Ranking is semver_branch_rank's job -- branch-M.x sorts above
every branch-M.N,
+ and a ref it cannot classify as a maintenance branch gets its (-1, -1)
sentinel. Those
+ refs are treated as carrying the API: fail-open, so master, a tag, or a
custom ref never
+ raises a false warning. The committer sees nothing in that case, which is
the right trade:
+ a backport additionally gets maybe_compile_check right after this -- the
check that covers
+ everything rather than one known API -- and the fail-open refs on a direct
merge (master,
+ a tag, a custom ref) are not pre-4.2 branches in the first place.
+
+ >>> _is_pre_binding_policy_branch("master")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.x")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.2")
+ False
+ >>> _is_pre_binding_policy_branch("branch-4.1")
+ True
+ >>> _is_pre_binding_policy_branch("HEAD")
+ False
+ >>> _is_pre_binding_policy_branch("my-custom-ref")
+ False
+ """
+ rank = semver_branch_rank(ref)
+ return rank != (-1, -1) and rank < (4, 2)
+
+
+def _added_binding_policy_tokens(diff: str) -> list[str]:
+ """Return the sorted subset of BINDING_POLICY_PATTERNS matched on added
(``+``) lines.
+
+ Only scans lines the diff added (a leading ``+`` that is not the ``+++ ``
file header --
+ the trailing space keeps an added line whose own text starts with ``++``
counted, apart
+ from the ``++ <space>`` case no real Scala/Java line starts with), so a
cherry-pick that
+ *removes* a stray .withBindingPolicy is not flagged. Matching uses the
+ precise patterns from BINDING_POLICY_PATTERNS, so a comment that merely
mentions
+ ``withBindingPolicy`` (no leading dot, no call) is not flagged;
``ConfigBindingPolicy`` in
+ a comment still can match, which is fine -- the prompt is advisory.
+
+ >>> _added_binding_policy_tokens(
+ ... "+++ b/foo\\n"
+ ... "+.withBindingPolicy(X)\\n"
+ ... "-.withBindingPolicy(Y)\\n"
+ ... "+import ...ConfigBindingPolicy\\n"
+ ... "+// see withBindingPolicy usage\\n"
+ ... " context\\n"
+ ... )
+ ['ConfigBindingPolicy', 'withBindingPolicy']
+ >>> _added_binding_policy_tokens("-.withBindingPolicy(X)\\n")
+ []
+ >>> _added_binding_policy_tokens("+// mentions ConfigBindingPolicy in a
comment\\n")
+ ['ConfigBindingPolicy']
+ >>> _added_binding_policy_tokens("")
+ []
+ """
+ found = set()
+ for line in diff.splitlines():
+ if line.startswith("+") and not line.startswith("+++ "):
+ for token, pattern in BINDING_POLICY_PATTERNS:
+ if pattern.search(line):
+ found.add(token)
+ return sorted(found)
+
+
+def maybe_binding_policy_warning(ref: str, base_head: str, cherry: bool =
False) -> None:
+ """Warn before pushing to a pre-4.2 `ref` a change that adds
ConfigBindingPolicy.
+
+ A merge or cherry-pick onto branch-4.1/4.0/3.x that introduces
``.withBindingPolicy(...)``
+ or ``ConfigBindingPolicy`` will not compile there (the API arrived in
SPARK-55928, 4.2+).
+ This is a git-diff + grep pre-push check: it diffs `base_head` (the branch
tip before the
+ merge or pick) against HEAD restricted to Scala/Java sources, scans only
the added lines
+ for the precise patterns in BINDING_POLICY_PATTERNS, and on a hit prints
the remedy and
+ interactively asks the committer to confirm the push. No-op on branches
that carry the
+ API, so master/branch-4.x are unaffected. `cherry` decides whether
declining skips just
+ this branch or exits (see continue_maybe).
+
+ The diff is forced plain: without --no-color a committer with ``color.ui =
always`` gets
+ added lines behind an ANSI escape, no line starts with "+", and the check
reports nothing
+ while looking like it ran. Like branches_with_merge_footer, a git failure
only warns: on
+ the cherry-pick path this runs after master has already been pushed, and
on the
+ direct-merge path a broken grep must not be the thing that blocks a good
merge either.
Review Comment:
**Nit (P3):** `merge_pr` calls neither check now. The only callers are at
1572-1573, both with `cherry=True`, and nothing imports this script. But the
text added in this PR still describes a merge caller: 'A merge or cherry-pick
onto branch-4.1/4.0/3.x' (1255), the 'direct-merge path' sentence here
(1267-1268), 'the fail-open refs on a direct merge' (1196-1197), 'A merge or
backport to a pre-4.2 branch' (127), and 'the merge (or cherry-pick) about to
be pushed' (1013-1014). The `cherry=False` defaults (1010, 1252), whose decline
exits the script, are used only by the doctests 'A red build aborts the merge'
(1088) and 'Declining aborts the merge' (1317). If skipping direct merges is
intended, as 702d029344e says, drop that wording, the `cherry=False` mode and
those doctests, along with `target_head`. Otherwise restore the merge-path call.
See **Shared repair plan 3** in the review body.
##########
dev/merge_spark_pr.py:
##########
@@ -901,12 +1462,74 @@ def merge_pr(pr_num, target_ref, title, body,
pr_repo_desc, pr_author, co_author
return merge_hash
-def _do_cherry_pick(pr_num, merge_hash, pick_ref):
+def _backport_pr_branch(pr_num, pick_ref):
+ """Fork branch name for a backport PR: stable, PR-traceable,
branch-suffixed.
+
+ >>> _backport_pr_branch(123, "branch-4.2")
+ 'backport-pr123-branch-4.2'
+ >>> _backport_pr_branch(9, "branch-3.5")
+ 'backport-pr9-branch-3.5'
+ """
+ return "backport-pr%s-%s" % (pr_num, pick_ref)
+
+
+def _backport_pr_title(pick_ref, title):
+ """PR title for a backport PR: the merged title tagged with its target
branch.
+
+ >>> _backport_pr_title("branch-4.2", "[SPARK-1234][SQL] Fix a thing")
+ '[BACKPORT-branch-4.2] [SPARK-1234][SQL] Fix a thing'
+ """
+ return "[BACKPORT-%s] %s" % (pick_ref, title)
+
+
+def _fork_owner():
+ """GitHub owner of the fork remote, for the `head` field of a backport PR.
+
+ FORK_OWNER wins when set; otherwise the owner is parsed from the fork
remote URL
+ ([email protected]:owner/spark.git or https://github.com/owner/spark.git). A
non-GitHub
+ remote fails with a clear message, rather than silently sending a bad
`head` to the
+ API, so the committer can set FORK_OWNER.
+ """
+ if FORK_OWNER:
+ return FORK_OWNER
+ url = git.run(["git", "remote", "get-url", FORK_REMOTE_NAME]).strip()
+ m = re.search(r"github\.com[:/]([^/]+)/", url)
Review Comment:
**Nit (P3):** For `ssh://[email protected]:443/<owner>/spark.git`, the
port-443 form GitHub documents for networks that block port 22, this pattern
captures `443`. I ran it; the scp, https and `ssh://[email protected]/...` forms
parse correctly. The push still works, because git uses the URL, but
`create_pr` is sent `head='443:backport-pr<N>-<branch>'`. GitHub rejects that,
so the fallback records `fork:443:...`, and the public merge comment says
'backport pushed to <branch> 443:backport-... (PR not opened)'. Allowing an
optional `:port`, or parsing the URL properly as part of the
destination-identity fix, resolves it. Setting `FORK_OWNER` is the workaround.
See **Shared repair plan 2** in the review body.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]