This is an automated email from the ASF dual-hosted git repository.

Yicong-Huang 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 7782448cd2f8 [SPARK-58929][INFRA] Add a dry-run mode to 
merge_spark_pr.py
7782448cd2f8 is described below

commit 7782448cd2f8cfd74f8ae0677930304154506047
Author: Yicong Huang <[email protected]>
AuthorDate: Tue Aug 25 18:00:44 2026 +0000

    [SPARK-58929][INFRA] Add a dry-run mode to merge_spark_pr.py
    
    ### What changes were proposed in this pull request?
    
    Adds a dry-run mode to `dev/merge_spark_pr.py` that runs every read-only 
step for real but suppresses every effect that leaves the local machine, 
logging a `DRY-RUN: would <action> ...` line instead.
    
    Every production mutation goes through an injectable client with a 
`Production` and a `DryRun` subclass, and `main()` constructs the `DryRun` 
variants when `--dry-run` is set, so the dry-run behavior lives entirely in 
those subclasses:
    
    - `Git` / `DryRunGit`: git I/O. `push()` -- the only command that mutates 
`PUSH_REMOTE_NAME` -- is the one method `DryRunGit` overrides; 
fetch/checkout/merge/commit/cherry-pick/rev-parse still run for real on the 
throwaway `PR_TOOL_*` branches that `clean_up` removes, so conflicts and the 
computed merge hash stay realistic.
    - `GitHub` / `DryRunGitHub`: `close_pr()` and `comment()`. Reads go through 
the module-level `get_json()`.
    - `Jira` / `DryRunJira`: component and fixVersion updates, assignee, the 
contributor-role grant, and the resolve transition. Reads stay on the 
module-level `asf_jira` client.
    
    Read-only steps still run so the flow is faithful: fetching the PR, the 
JIRA issue lookup and `project_versions`, and JIRA/GitHub token validation 
(`initialize_jira()` and `get_json()` are not routed through the dry-run 
clients), so a dry run exercises the same credentials a real merge needs. 
Interactive prompts continue to work.
    
    Dry-run is enabled with a `--dry-run` / `-n` flag (the CLI now uses 
`argparse`) or the `DRY_RUN` environment variable, which seeds the flag's 
default -- consistent with the existing `SKIP_VERSION_CHECK` env toggle in this 
script.
    
    ### Why are the changes needed?
    
    Before this change there was no way to rehearse the full merge flow without 
producing real side effects. The existing affordances cover only part of it: 
doctests exercise the pure logic; `SKIP_VERSION_CHECK` lets a committer iterate 
against a real PR; and `PR_REMOTE_NAME` / `PUSH_REMOTE_NAME` can point the `git 
push` at a scratch fork. But `GITHUB_API_BASE` and `JIRA_API_BASE` are 
hardcoded to apache, so the PR close/comment and JIRA resolve paths cannot be 
redirected and always hit t [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is a committer-facing dev tool; the new mode is opt-in and the 
default behavior is unchanged.
    
    ### How was this patch tested?
    
    - Doctests: `python -m doctest dev/merge_spark_pr.py` passes.
    - Manual smoke test of the dry-run clients: `DryRunGit.push` logs and does 
not push while `DryRunGit.run` still executes read-only git; 
`DryRunGitHub.close_pr`/`comment` log; 
`DryRunJira.update_components`/`add_fix_versions`/`resolve_issue`/`assign`/`grant_contributor`
 all log and make no API call. With dry-run off, the default `Production` 
clients are used and behavior is unchanged.
    - `argparse` parsing verified for the optional PR-number positional and the 
`--dry-run`/`-n` flag, including the `DRY_RUN` env default.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    No.
    
    Closes #58198 from Yicong-Huang/add-dry-run-merge-script.
    
    Authored-by: Yicong Huang <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
---
 dev/merge_spark_pr.py | 369 +++++++++++++++++++++++++++++++++++---------------
 1 file changed, 263 insertions(+), 106 deletions(-)

diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py
index e7cb861c073b..366181a4b380 100755
--- a/dev/merge_spark_pr.py
+++ b/dev/merge_spark_pr.py
@@ -37,6 +37,7 @@
 # have added remotes corresponding to both (i) the github apache Spark
 # mirror and (ii) the apache git repo.
 
+import argparse
 import json
 import os
 import re
@@ -82,6 +83,16 @@ JIRA_CONNECT_TIMEOUT = 
float(os.environ.get("JIRA_CONNECT_TIMEOUT", "3.05"))
 # exceeding your IP's unauthenticated request rate limit. You can create an 
OAuth key at
 # https://github.com/settings/tokens. This script only requires the 
"public_repo" scope.
 GITHUB_OAUTH_KEY = os.environ.get("GITHUB_OAUTH_KEY")
+# Setting the DRY_RUN env var to any non-empty value makes the --dry-run/-n 
flag default to on
+# (see build_arg_parser); the flag is the primary interface and can also turn 
it on explicitly.
+# Consistent with the SKIP_VERSION_CHECK env toggle above. In a dry run every 
read-only step and
+# local git op still runs -- fetching the PR, JIRA lookup, project_versions, 
JIRA/GitHub token
+# validation, and the local squash-merge and cherry-picks on the throwaway 
PR_TOOL_* branches so
+# conflicts and the computed merge hash stay realistic -- but every outbound 
effect is routed
+# through a DryRun* client (see Git/GitHub/Jira below) that logs a "DRY-RUN: 
would ..." line
+# instead of executing it: the git push to PUSH_REMOTE_NAME, the GitHub PR 
close/comment, and all
+# JIRA writes (component and fixVersion updates, assignment, and the resolve 
transition).
+DRY_RUN_ENV = bool(os.environ.get("DRY_RUN"))
 
 
 GITHUB_BASE = "https://github.com/apache/spark/pull";
@@ -483,36 +494,63 @@ def find_merge_commit(pr_num, pr_events):
     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")
-    request = Request(url, data=data, method="PATCH")
-    request.add_header("Content-Type", "application/json")
-    request.add_header("Accept", "application/vnd.github+json")
-    if GITHUB_OAUTH_KEY:
-        request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY)
-    try:
-        return json.load(urlopen(request))
-    except HTTPError as e:
-        print_error("Failed to close PR #%s: HTTP %s %s" % (pr_num, e.code, 
e.reason))
-        return None
+class GitHub:
+    """GitHub REST writes used by the merge script -- the single seam for PR 
mutations.
+
+    Reads go through the module-level get_json(); only the two mutations 
(closing the PR and
+    posting a comment) live here, so DryRunGitHub can override them without 
touching any read
+    path. main() constructs GitHub() normally and DryRunGitHub() for a dry run.
+    """
 
+    def close_pr(self, pr_num):
+        url = "%s/pulls/%s" % (GITHUB_API_BASE, pr_num)
+        data = json.dumps({"state": "closed"}).encode("utf-8")
+        request = Request(url, data=data, method="PATCH")
+        request.add_header("Content-Type", "application/json")
+        request.add_header("Accept", "application/vnd.github+json")
+        if GITHUB_OAUTH_KEY:
+            request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY)
+        try:
+            return json.load(urlopen(request))
+        except HTTPError as e:
+            print_error("Failed to close PR #%s: HTTP %s %s" % (pr_num, 
e.code, e.reason))
+            return None
 
-def comment_pr(pr_num, body):
-    url = "%s/issues/%s/comments" % (GITHUB_API_BASE, pr_num)
-    data = json.dumps({"body": body}).encode("utf-8")
-    request = Request(url, data=data, method="POST")
-    request.add_header("Content-Type", "application/json")
-    request.add_header("Accept", "application/vnd.github+json")
-    if GITHUB_OAUTH_KEY:
+    def comment(self, pr_num, body):
+        # Posting a comment is a write and needs auth; without a token, skip 
rather than 401.
+        if not GITHUB_OAUTH_KEY:
+            print_error("GITHUB_OAUTH_KEY is not set; skipping the comment.")
+            return None
+        url = "%s/issues/%s/comments" % (GITHUB_API_BASE, pr_num)
+        data = json.dumps({"body": body}).encode("utf-8")
+        request = Request(url, data=data, method="POST")
+        request.add_header("Content-Type", "application/json")
+        request.add_header("Accept", "application/vnd.github+json")
         request.add_header("Authorization", "token %s" % GITHUB_OAUTH_KEY)
-    try:
-        return json.load(urlopen(request))
-    except HTTPError as e:
-        print_error("Failed to comment on PR #%s: HTTP %s %s" % (pr_num, 
e.code, e.reason))
+        try:
+            return json.load(urlopen(request))
+        except HTTPError as e:
+            print_error("Failed to comment on PR #%s: HTTP %s %s" % (pr_num, 
e.code, e.reason))
+            return None
+
+
+class DryRunGitHub(GitHub):
+    """Logs the intended PR close/comment instead of calling the GitHub API."""
+
+    def close_pr(self, pr_num):
+        print("DRY-RUN: would close PR #%s via the GitHub API." % pr_num)
+        return None
+
+    def comment(self, pr_num, body):
+        print("DRY-RUN: would post the following comment on PR #%s:\n%s" % 
(pr_num, body))
         return None
 
 
+# Set in main(): GitHub() normally, DryRunGitHub() for a dry run. Module-level 
default so the
+# top-level except handler and any early failure path have a usable client.
+github = GitHub()
+
+
 def post_merge_comment(pr_num, merged_commits):
     """Post a comment on the PR recording every branch the change landed on 
and a
     link to the resulting commit, so the merge is traceable from the PR page.
@@ -533,10 +571,7 @@ def post_merge_comment(pr_num, merged_commits):
         "\n%s\n\n%s\n%s"
         % (bold("Posting merge comment on PR #%s:" % pr_num), bold(summary), 
attribution)
     )
-    if not GITHUB_OAUTH_KEY:
-        print_error("GITHUB_OAUTH_KEY is not set; skipping the merge comment.")
-        return
-    comment_pr(pr_num, body)
+    github.comment(pr_num, body)
 
 
 def fail(msg):
@@ -545,12 +580,41 @@ def fail(msg):
     sys.exit(-1)
 
 
-def run_cmd(cmd):
-    print(cmd)
-    if isinstance(cmd, list):
-        return subprocess.check_output(cmd).decode("utf-8")
-    else:
-        return subprocess.check_output(cmd.split(" ")).decode("utf-8")
+class Git:
+    """Runs the merge-flow git commands for the merge script.
+
+    This is the seam for the merge/backport flow, not every git invocation:
+    check_script_up_to_date() shells out to git merge-base directly. ``run`` 
executes read-only
+    and local commands (fetch, checkout, merge, commit, cherry-pick, 
rev-parse, config, branch
+    bookkeeping); those are safe even in a dry run because the merge and 
cherry-picks happen on
+    the throwaway PR_TOOL_* branches that clean_up removes. ``push`` is the 
only command that
+    mutates the shared repo at PUSH_REMOTE_NAME, so it is the one method 
DryRunGit overrides;
+    everything else it inherits and runs for real, keeping conflict detection 
and the computed
+    merge hash realistic. main() constructs Git() normally and DryRunGit() for 
a dry run.
+    """
+
+    def run(self, cmd):
+        print(cmd)
+        if isinstance(cmd, list):
+            return subprocess.check_output(cmd).decode("utf-8")
+        else:
+            return subprocess.check_output(cmd.split(" ")).decode("utf-8")
+
+    def push(self, remote, local_ref, remote_ref):
+        return self.run("git push %s %s:%s" % (remote, local_ref, remote_ref))
+
+
+class DryRunGit(Git):
+    """Logs the intended push instead of updating the remote; all other git 
runs for real."""
+
+    def push(self, remote, local_ref, remote_ref):
+        print("DRY-RUN: would push %s to %s:%s" % (local_ref, remote, 
remote_ref))
+        return ""
+
+
+# Set in main(): Git() normally, DryRunGit() for a dry run. Module-level 
default so clean_up and
+# the top-level except handler have a usable client even if main() fails early.
+git = Git()
 
 
 class SkipCherryPick(Exception):
@@ -567,7 +631,7 @@ def continue_maybe(prompt, cherry=False):
     if get_input(f"{prompt} (y/N): ", ["y", "n", ""]) != "y":
         if cherry:
             try:
-                run_cmd("git cherry-pick --abort")
+                git.run("git cherry-pick --abort")
             except Exception:
                 print_error("Unable to abort and get back to the state before 
cherry-pick")
             print("Skipping this cherry-pick; the merge continues.")
@@ -579,26 +643,26 @@ def continue_maybe(prompt, cherry=False):
 def clean_up():
     if "original_head" in globals():
         print("Restoring head pointer to %s" % original_head)
-        run_cmd("git checkout %s" % original_head)
+        git.run("git checkout %s" % original_head)
 
-        branches = run_cmd("git branch").replace(" ", "").split("\n")
+        branches = git.run("git branch").replace(" ", "").split("\n")
 
         for branch in list(filter(lambda x: x.startswith(BRANCH_PREFIX), 
branches)):
             print("Deleting local branch %s" % branch)
-            run_cmd("git branch -D %s" % branch)
+            git.run("git branch -D %s" % branch)
 
 
 # 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())
-    run_cmd("git fetch %s pull/%s/head:%s" % (PR_REMOTE_NAME, pr_num, 
pr_branch_name))
-    run_cmd("git fetch %s %s:%s" % (PUSH_REMOTE_NAME, target_ref, 
target_branch_name))
-    run_cmd("git checkout %s" % target_branch_name)
+    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)
 
     had_conflicts = False
     try:
-        run_cmd(["git", "merge", pr_branch_name, "--squash"])
+        git.run(["git", "merge", pr_branch_name, "--squash"])
     except Exception as e:
         msg = "Error merging: %s\nWould you like to manually fix-up this 
merge?" % e
         continue_maybe(msg)
@@ -621,8 +685,8 @@ def merge_pr(pr_num, target_ref, title, body, pr_repo_desc, 
pr_author, co_author
         # to people every time someone creates a public fork of Spark.
         merge_message_flags += ["-m", body.replace("@", "")]
 
-    committer_name = run_cmd("git config --get user.name").strip()
-    committer_email = run_cmd("git config --get user.email").strip()
+    committer_name = git.run("git config --get user.name").strip()
+    committer_email = git.run("git config --get user.email").strip()
 
     if had_conflicts:
         message = "This patch had conflicts when merged, resolved 
by\nCommitter: %s <%s>" % (
@@ -642,19 +706,19 @@ def merge_pr(pr_num, target_ref, title, body, 
pr_repo_desc, pr_author, co_author
 
     merge_message_flags += ["-m", authors]
 
-    run_cmd(["git", "commit", '--author="%s"' % primary_author] + 
merge_message_flags)
+    git.run(["git", "commit", '--author="%s"' % primary_author] + 
merge_message_flags)
 
     continue_maybe(
         "Merge complete (local ref %s). Push to %s?" % (target_branch_name, 
PUSH_REMOTE_NAME)
     )
 
     try:
-        run_cmd("git push %s %s:%s" % (PUSH_REMOTE_NAME, target_branch_name, 
target_ref))
+        git.push(PUSH_REMOTE_NAME, target_branch_name, target_ref)
     except Exception as e:
         clean_up()
         print_error("Exception while pushing: %s" % e)
 
-    merge_hash = run_cmd("git rev-parse %s" % target_branch_name).strip()
+    merge_hash = git.run("git rev-parse %s" % target_branch_name).strip()
     clean_up()
     print("Pull request #%s merged!" % pr_num)
     print("Merge hash: %s" % merge_hash)
@@ -670,11 +734,11 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
     """
     pick_branch_name = "%s_PICK_PR_%s_%s" % (BRANCH_PREFIX, pr_num, 
pick_ref.upper())
 
-    run_cmd("git fetch %s %s:%s" % (PUSH_REMOTE_NAME, pick_ref, 
pick_branch_name))
-    run_cmd("git checkout %s" % pick_branch_name)
+    git.run("git fetch %s %s:%s" % (PUSH_REMOTE_NAME, pick_ref, 
pick_branch_name))
+    git.run("git checkout %s" % pick_branch_name)
 
     try:
-        run_cmd("git cherry-pick -sx %s" % merge_hash)
+        git.run("git cherry-pick -sx %s" % merge_hash)
     except Exception as e:
         msg = "Error cherry-picking: %s\nWould you like to manually fix-up 
this merge?" % e
         continue_maybe(msg, True)
@@ -686,11 +750,11 @@ def _do_cherry_pick(pr_num, merge_hash, pick_ref):
     )
 
     try:
-        run_cmd("git push %s %s:%s" % (PUSH_REMOTE_NAME, pick_branch_name, 
pick_ref))
+        git.push(PUSH_REMOTE_NAME, pick_branch_name, pick_ref)
     except Exception as e:
         fail("Exception while pushing: %s" % e)
 
-    pick_hash = run_cmd("git rev-parse %s" % pick_branch_name).strip()
+    pick_hash = git.run("git rev-parse %s" % pick_branch_name).strip()
     clean_up()
 
     print("Pull request #%s picked into %s!" % (pr_num, pick_ref))
@@ -709,7 +773,7 @@ def branches_with_merge_footer(pr_num, branch_names):
     """
     try:
         landed = _branches_with_merge_footer(
-            pr_num, PUSH_REMOTE_NAME, lambda args: run_cmd(["git"] + args)
+            pr_num, PUSH_REMOTE_NAME, lambda args: git.run(["git"] + args)
         )
     except Exception as e:
         print_error("Could not scan for existing backports of #%s (%s)." % 
(pr_num, e))
@@ -1112,11 +1176,7 @@ def reconcile_jira_components(issue, title_components):
         # Append the PR title's components, keeping the existing ones first.
         new_names = list(dict.fromkeys(current + title_jira_components))
 
-    try:
-        issue.update(fields={"components": [{"name": n} for n in new_names]})
-        print("Updated JIRA %s components to: %s" % (issue.key, ", 
".join(new_names)))
-    except Exception as e:
-        print_error("Failed to update components on JIRA %s: %s" % (issue.key, 
e))
+    jira_ops.update_components(issue, new_names)
 
 
 def get_jira_issue(prompt, default_jira_id=""):
@@ -1241,37 +1301,17 @@ def resolve_jira_issue(
         if not jira_fix_versions:
             print("No new fix versions selected for JIRA issue %s; no update 
needed." % issue.key)
             return
-        issue.update(
-            fields={"fixVersions": [v.raw for v in existing_fix_versions] + 
jira_fix_versions}
-        )
-        try:
-            print_jira_issue_summary(asf_jira.issue(issue.key))
-        except Exception:
-            print("Unable to fetch JIRA issue %s after updating fix versions" 
% issue.key)
-        print(
-            "Successfully updated %s with additional fixVersions=%s!"
-            % (issue.key, [v["name"] for v in jira_fix_versions])
-        )
+        jira_ops.add_fix_versions(issue, existing_fix_versions, 
jira_fix_versions)
         return
 
     resolve = list(filter(lambda a: a["name"] == "Resolve Issue", 
asf_jira.transitions(issue.key)))[
         0
     ]
     resolution = list(filter(lambda r: r.raw["name"] == "Fixed", 
asf_jira.resolutions()))[0]
-    asf_jira.transition_issue(
-        issue.key,
-        resolve["id"],
-        fixVersions=jira_fix_versions,
-        comment=comment,
-        resolution={"id": resolution.raw["id"]},
+    jira_ops.resolve_issue(
+        issue, resolve["id"], jira_fix_versions, comment, 
resolution.raw["id"], fix_versions
     )
 
-    try:
-        print_jira_issue_summary(asf_jira.issue(issue.key))
-    except Exception:
-        print("Unable to fetch JIRA issue %s after resolving" % issue.key)
-    print("Successfully resolved %s with fixVersions=%s!" % (issue.key, 
fix_versions))
-
 
 def choose_jira_assignee(issue):
     """
@@ -1306,7 +1346,7 @@ def choose_jira_assignee(issue):
                     # assume it's a user id, and try to assign (might fail, we 
just prompt again)
                     assignee = asf_jira.user(raw_assignee)
                 try:
-                    assign_issue(issue.key, assignee.name)
+                    jira_ops.assign(issue.key, assignee.name)
                 except Exception as e:
                     if (
                         e.__class__.__name__ == "JIRAError"
@@ -1317,8 +1357,8 @@ def choose_jira_assignee(issue):
                             "User '%s' cannot be assigned, add to contributors 
role and try again?"
                             % assignee.name
                         )
-                        grant_contributor_role(assignee.name)
-                        assign_issue(issue.key, assignee.name)
+                        jira_ops.grant_contributor(assignee.name)
+                        jira_ops.assign(issue.key, assignee.name)
                     else:
                         raise e
                 return assignee
@@ -1329,23 +1369,101 @@ def choose_jira_assignee(issue):
             print("Error assigning JIRA, try again (or leave blank and fix 
manually)")
 
 
-def grant_contributor_role(user: str):
-    role = asf_jira.project_role("SPARK", 10010)
-    role.add_user(user)
-    print("Successfully added user '%s' to contributors role" % user)
-
+class Jira:
+    """ASF JIRA writes used by the merge script -- the single seam for JIRA 
mutations.
 
-def assign_issue(issue: int, assignee: str) -> bool:
-    """
-    Assign an issue to a user, which is a shorthand for 
jira.client.JIRA.assign_issue.
-    The original one has an issue that it will search users again and only 
choose the assignee
-    from 20 candidates. If it's unmatched, it picks the head blindly. In our 
case, the assignee
-    is already resolved.
+    The pre-write JIRA lookups stay on the module-level asf_jira client; the 
writes (components,
+    fix versions, the resolve transition, assignment, and the contributor-role 
grant) go through
+    here, so DryRunJira can log them. Two Production writes (add_fix_versions, 
resolve_issue) also
+    read the issue back afterward to print the updated summary; DryRunJira 
skips the write and that
+    read-back alike. main() builds Jira(asf_jira) normally and 
DryRunJira(asf_jira) for a dry run,
+    after initialize_jira() sets asf_jira.
     """
-    url = getattr(asf_jira, "_get_latest_url")(f"issue/{issue}/assignee")
-    payload = {"name": assignee}
-    getattr(asf_jira, "_session").put(url, data=json.dumps(payload))
-    return True
+
+    def __init__(self, client):
+        self._client = client
+
+    def update_components(self, issue, new_names):
+        try:
+            issue.update(fields={"components": [{"name": n} for n in 
new_names]})
+            print("Updated JIRA %s components to: %s" % (issue.key, ", 
".join(new_names)))
+        except Exception as e:
+            print_error("Failed to update components on JIRA %s: %s" % 
(issue.key, e))
+
+    def add_fix_versions(self, issue, existing_fix_versions, 
new_version_jsons):
+        issue.update(
+            fields={"fixVersions": [v.raw for v in existing_fix_versions] + 
new_version_jsons}
+        )
+        try:
+            print_jira_issue_summary(self._client.issue(issue.key))
+        except Exception:
+            print("Unable to fetch JIRA issue %s after updating fix versions" 
% issue.key)
+        print(
+            "Successfully updated %s with additional fixVersions=%s!"
+            % (issue.key, [v["name"] for v in new_version_jsons])
+        )
+
+    def resolve_issue(
+        self, issue, resolve_id, fix_version_jsons, comment, resolution_id, 
fix_version_names
+    ):
+        self._client.transition_issue(
+            issue.key,
+            resolve_id,
+            fixVersions=fix_version_jsons,
+            comment=comment,
+            resolution={"id": resolution_id},
+        )
+        try:
+            print_jira_issue_summary(self._client.issue(issue.key))
+        except Exception:
+            print("Unable to fetch JIRA issue %s after resolving" % issue.key)
+        print("Successfully resolved %s with fixVersions=%s!" % (issue.key, 
fix_version_names))
+
+    def assign(self, issue_key, assignee):
+        # Shorthand for jira.client.JIRA.assign_issue. The library's own 
assign_issue re-searches
+        # users and blindly picks the first of 20 candidates when unmatched; 
here the assignee is
+        # already resolved, so PUT it directly.
+        url = getattr(self._client, 
"_get_latest_url")(f"issue/{issue_key}/assignee")
+        getattr(self._client, "_session").put(url, data=json.dumps({"name": 
assignee}))
+        return True
+
+    def grant_contributor(self, user):
+        role = self._client.project_role("SPARK", 10010)
+        role.add_user(user)
+        print("Successfully added user '%s' to contributors role" % user)
+
+
+class DryRunJira(Jira):
+    """Logs the intended JIRA writes instead of calling the API; reads still 
go through."""
+
+    def update_components(self, issue, new_names):
+        print("DRY-RUN: would set JIRA %s components to: %s" % (issue.key, ", 
".join(new_names)))
+
+    def add_fix_versions(self, issue, existing_fix_versions, 
new_version_jsons):
+        print(
+            "DRY-RUN: would add fixVersions=%s to JIRA %s."
+            % ([v["name"] for v in new_version_jsons], issue.key)
+        )
+
+    def resolve_issue(
+        self, issue, resolve_id, fix_version_jsons, comment, resolution_id, 
fix_version_names
+    ):
+        print(
+            "DRY-RUN: would resolve JIRA %s as Fixed with fixVersions=%s and 
add comment:\n%s"
+            % (issue.key, fix_version_names, comment)
+        )
+
+    def assign(self, issue_key, assignee):
+        print("DRY-RUN: would assign JIRA %s to '%s'." % (issue_key, assignee))
+        return True
+
+    def grant_contributor(self, user):
+        print("DRY-RUN: would add user '%s' to the SPARK contributors role." % 
user)
+
+
+# Set in main() after initialize_jira(): Jira(asf_jira) normally, 
DryRunJira(asf_jira) for a dry
+# run. None until then; only the JIRA-write flow, well after construction, 
uses it.
+jira_ops = None
 
 
 def resolve_jira_issues(title, merge_branches, comment, title_components=()):
@@ -1671,10 +1789,10 @@ def prompt_for_components():
 
 
 def get_current_ref():
-    ref = run_cmd("git rev-parse --abbrev-ref HEAD").strip()
+    ref = git.run("git rev-parse --abbrev-ref HEAD").strip()
     if ref == "HEAD":
         # The current ref is a detached HEAD, so grab its SHA.
-        return run_cmd("git rev-parse HEAD").strip()
+        return git.run("git rev-parse HEAD").strip()
     else:
         return ref
 
@@ -1778,10 +1896,49 @@ def check_script_up_to_date():
     )
 
 
+def build_arg_parser():
+    """CLI parser: an optional PR number plus the --dry-run/-n flag.
+
+    --dry-run defaults to on when the DRY_RUN env var is set to any non-empty 
value (see the
+    DRY_RUN_ENV note near the top of this file), so either the flag or the env 
var enables a
+    dry run; passing the flag forces it on regardless.
+    """
+    parser = argparse.ArgumentParser(description="Merge a Spark pull request.")
+    parser.add_argument(
+        "pr_num",
+        nargs="?",
+        help="Pull request number to merge; prompted for interactively if 
omitted.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        "-n",
+        action="store_true",
+        default=DRY_RUN_ENV,
+        help="Run every read-only step for real but suppress all outbound 
effects (git push, "
+        "GitHub PR close/comment, JIRA writes), logging each as a 'DRY-RUN: 
would ...' line. "
+        "Also enabled by setting the DRY_RUN environment variable.",
+    )
+    return parser
+
+
 def main():
+    global git, github, jira_ops, original_head
+    args = build_arg_parser().parse_args()
+    if args.dry_run:
+        # Swap the outbound clients for their logging-only variants; 
everything else is unchanged.
+        git = DryRunGit()
+        github = DryRunGitHub()
+        print(
+            bold(
+                "=== DRY-RUN: read-only steps run for real, but the git push 
to %s, the GitHub "
+                "PR close/comment, and all JIRA writes are suppressed and only 
logged. ==="
+                % PUSH_REMOTE_NAME
+            )
+        )
+
     check_script_up_to_date()
     initialize_jira()
-    global original_head
+    jira_ops = DryRunJira(asf_jira) if args.dry_run else Jira(asf_jira)
 
     os.chdir(SPARK_HOME)
     original_head = get_current_ref()
@@ -1790,10 +1947,10 @@ def main():
     branch_names = list(filter(lambda x: x.startswith("branch-"), [x["name"] 
for x in branches]))
     branch_names = sorted(branch_names, key=semver_branch_rank, reverse=True)
 
-    if len(sys.argv) == 1:
+    if args.pr_num is None:
         pr_num = get_input("Which pull request would you like to merge? (e.g. 
34): ", r"^\d+$")
     else:
-        pr_num = sys.argv[1]
+        pr_num = args.pr_num
         print("Start to merge pull request #%s" % (pr_num))
     pr = get_json("%s/pulls/%s" % (GITHUB_API_BASE, pr_num))
     pr_events = get_json("%s/issues/%s/events" % (GITHUB_API_BASE, pr_num))
@@ -1927,7 +2084,7 @@ def main():
     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()
+            git.run(["git", "rev-parse", "--quiet", "--verify", "%s^{commit}" 
% merge_hash]).strip()
             != ""
         )
         if not commit_is_downloaded:
@@ -2085,7 +2242,7 @@ def main():
             if pr_state != "closed":
                 close_message = "PR #%s is still open after push; closing it 
explicitly." % pr_num
                 print("\n%s\n" % bold(close_message))
-                close_pr(pr_num)
+                github.close_pr(pr_num)
             # Record every branch that successfully received the change on the 
PR.
             post_merge_comment(pr_num, merged_commits)
         # This is deliberately in the finally block: once the target branch 
has been pushed,


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to