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

gengliangwang 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 16dbbe2c8a54 [SPARK-58045][INFRA] Verify the linked JIRA matches the 
PR in `merge_spark_pr.py`
16dbbe2c8a54 is described below

commit 16dbbe2c8a5482c3590c7f9738c5d4ecc4c69b55
Author: Gengliang Wang <[email protected]>
AuthorDate: Thu Jul 9 20:13:23 2026 -0700

    [SPARK-58045][INFRA] Verify the linked JIRA matches the PR in 
`merge_spark_pr.py`
    
    ### What changes were proposed in this pull request?
    
    This PR adds a JIRA-vs-PR verification step to `dev/merge_spark_pr.py`. 
After the committer enters the PR number (right after the existing 
Epic/Umbrella check, before the "Proceed with merging?" confirmation), the 
script now, for each SPARK JIRA id linked from the PR title, prints a 
verification block and requires an explicit `y/N` confirmation. The block:
    
    - prints the PR title next to the ticket's summary, status, and issue type, 
so a mismatch is easy to spot;
    - prints a red warning on the status line when the ticket is already 
`Resolved`/`Closed` (a fresh merge should target an open ticket);
    - prints a red warning when the PR title and the JIRA summary have low word 
overlap (a `Match:` score below a threshold), which is a strong signal the PR 
references the wrong ticket;
    - aborts the merge if the committer answers anything other than `y`, asking 
them to fix the PR title.
    
    Implementation notes:
    
    - A new pure helper `format_jira_verification(...)` renders the block and 
is covered by inline doctests. Coloring the warnings is gated behind a 
`use_color` flag (off in doctests so the expected output stays plain text, on 
at the real call site).
    - Title/summary similarity is a stdlib Jaccard word-overlap 
(`title_similarity`): bracket tags (`[SQL]`, the leading `[SPARK-xxxx]`, ...) 
are stripped, words are lowercased, a small stopword set is dropped, and the 
two token sets are compared. No new dependency. The warning fires below 
`SIMILARITY_WARN_THRESHOLD` (0.2), tuned so an unrelated ticket (~0) warns 
while reworded-but-correct summaries stay above the line.
    - The existing Epic/Umbrella loop already fetches each linked issue; it now 
also collects them into `linked_issues`, so the verification step reuses those 
objects and performs no extra JIRA round-trips.
    - A small `red(...)` helper was factored out of `print_error` (no behavior 
change) and reused for the warnings.
    
    No JIRA id is newly required by this change: `[MINOR]`/`[TRIVIAL]` PRs 
still merge with no SPARK id (the loop is a no-op for them). The existing 
`Title.parse` requirement is unchanged. `[FOLLOWUP]` PRs are handled (the id is 
extracted regardless of the tag), and `Revert "..."` PRs keep their title 
verbatim so the id is still extracted from it -- in both cases the warnings act 
as the intended nudge to double-check the ticket.
    
    ### Why are the changes needed?
    
    `merge_spark_pr.py` already prints each linked JIRA's summary, but nothing 
forces the committer to confirm the ticket actually matches the PR. A PR can 
therefore be merged against the wrong ticket - for example when a GitHub PR 
number in the body (`Closes #NNNNN`) is mistaken for a SPARK JIRA id - which 
resolves an unrelated, possibly already-Resolved ticket on merge. This step 
surfaces both signals (title/summary mismatch and an already-resolved ticket) 
and requires a human confirmat [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This only affects the committer-facing merge tooling.
    
    ### How was this patch tested?
    
    - `python3 -m doctest dev/merge_spark_pr.py` - all doctests pass, including 
the new `title_similarity` cases (match, no-match, partial overlap, 
`[FOLLOWUP]`, and `Revert` title patterns) and the two 
`format_jira_verification` cases (one exercising both warnings, one clean).
    - Manually rendered the block with `use_color=True` and confirmed both 
warnings are wrapped in the same red escape as `print_error`.
    - Verified all added lines are within the 100-char limit and contain no 
non-ASCII characters.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #57131 from gengliangwang/mergeScript.
    
    Authored-by: Gengliang Wang <[email protected]>
    Signed-off-by: Gengliang Wang <[email protected]>
---
 dev/merge_spark_pr.py | 211 +++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 210 insertions(+), 1 deletion(-)

diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py
index e3f542eb1ac6..09598adc8611 100755
--- a/dev/merge_spark_pr.py
+++ b/dev/merge_spark_pr.py
@@ -320,8 +320,12 @@ def compute_merge_default_fix_versions(merge_branches, 
unreleased_version_names)
     return list(dict.fromkeys(v for _, v in filtered)), warnings
 
 
+def red(text):
+    return "\033[91m%s\033[0m" % text
+
+
 def print_error(msg):
-    print("\033[91m%s\033[0m" % msg)
+    print(red(msg))
 
 
 def bold_input(prompt) -> str:
@@ -686,6 +690,181 @@ def cherry_pick(pr_num, merge_hash, default_branch, 
branch_names, target_ref, al
     return [_do_cherry_pick(pr_num, merge_hash, pick_ref)]
 
 
+# Common words carry no signal when comparing a PR title to a JIRA summary, so 
they are
+# dropped before scoring. Kept deliberately small: over-aggressive stopword 
removal makes
+# unrelated titles look similar. Component tags ([SQL], [CORE], ...) are 
stripped separately.
+_SIMILARITY_STOPWORDS = frozenset(
+    {
+        "a",
+        "an",
+        "the",
+        "to",
+        "for",
+        "of",
+        "in",
+        "on",
+        "and",
+        "or",
+        "with",
+        "is",
+        "are",
+        "be",
+        "when",
+        "should",
+        "make",
+        "add",
+        "fix",
+        "fixes",
+        "support",
+        "enable",
+        "use",
+        "using",
+    }
+)
+_SIMILARITY_WORD_RE = re.compile(r"[a-z0-9]+")
+# Below this Jaccard word-overlap score, warn that the PR title and JIRA 
summary look
+# unrelated. Tuned to flag clear mismatches (an unrelated ticket scores ~0) 
while leaving
+# paraphrases and reworded summaries (which still share key nouns) above the 
line.
+SIMILARITY_WARN_THRESHOLD = 0.2
+
+
+def title_similarity(pr_title, summary):
+    """Jaccard word-overlap between a PR title and a JIRA summary, in [0.0, 
1.0].
+
+    Component/version tags ([SQL], [4.x], ...) and the leading SPARK id are 
stripped,
+    words are lowercased, and common stopwords are dropped, so the score 
reflects the
+    substantive words the two share. 1.0 means identical word sets; 0.0 means 
none in
+    common (or an empty side).
+
+    >>> title_similarity("[SPARK-1][SQL] Compute stable checksum", "Compute a 
stable checksum")
+    1.0
+    >>> title_similarity("[SPARK-1][SQL] Compute stable checksum", "Refactor 
the logging backend")
+    0.0
+    >>> round(title_similarity("[SPARK-1] Ceil and floor overflow", "Handle 
floor overflow"), 2)
+    0.5
+
+    A follow-up title usually describes the fix it adds, not the original 
ticket, so
+    it scores low against the JIRA summary even though it references the right 
ticket
+    (the caller skips the low-similarity warning for [FOLLOWUP] PRs -- see
+    format_jira_verification):
+
+    >>> title_similarity("[SPARK-1][FOLLOWUP] Fix a typo", "Add a cache")
+    0.0
+
+    A Revert PR keeps its title verbatim (the "Revert" prefix and quotes are 
not
+    tags), so the extra word lowers the score but a genuine match still scores 
high:
+
+    >>> round(title_similarity('Revert "[SPARK-1] Add a cache"', "Add a 
cache"), 2)
+    0.5
+    """
+
+    def tokens(text):
+        text = re.sub(r"\[[^\]]*\]", " ", text)
+        words = _SIMILARITY_WORD_RE.findall(text.lower())
+        return {w for w in words if w not in _SIMILARITY_STOPWORDS}
+
+    a = tokens(pr_title)
+    b = tokens(summary)
+    if not a or not b:
+        return 0.0
+    return len(a & b) / len(a | b)
+
+
+def format_jira_verification(
+    pr_num, pr_title, jira_id, summary, status, issuetype, use_color=False, 
is_followup=False
+):
+    """Render the JIRA-vs-PR match block shown before merging.
+
+    Places the PR title next to the linked ticket's summary so the committer 
can
+    eyeball whether they match, flags a ticket that is already Resolved/Closed
+    (a fresh merge should target an open ticket), and scores how much the PR 
title
+    and the JIRA summary overlap -- a low score suggests the wrong ticket. Pure
+    formatting so it is covered by the inline doctests. ``use_color`` wraps the
+    warnings in the same red as ``print_error``; it is left off in the 
doctests so
+    the expected output stays plain text.
+
+    ``is_followup`` suppresses the low-similarity warning: a [FOLLOWUP] PR 
title
+    describes the fix it adds, not the original ticket, so it legitimately 
scores
+    low against the JIRA summary. The score is still shown for reference, but 
with
+    a note instead of a warning so the expected divergence isn't flagged as a
+    likely wrong ticket. The Resolved/Closed warning still applies.
+
+    >>> print(format_jira_verification(
+    ...     42,
+    ...     "[SPARK-2222][SQL] Compute stable checksum",
+    ...     "SPARK-1111",
+    ...     "Refactor the logging backend",
+    ...     "Resolved",
+    ...     "Improvement",
+    ... ))
+    === Verify JIRA matches PR #42 ===
+    PR title:  [SPARK-2222][SQL] Compute stable checksum
+    JIRA SPARK-1111: Refactor the logging backend
+      Status:  Resolved   <-- WARNING: already Resolved/Closed
+      Type:    Improvement
+      Match:   0.00   <-- WARNING: low title similarity, wrong ticket?
+
+    >>> print(format_jira_verification(
+    ...     42,
+    ...     "[SPARK-2222][SQL] Compute stable checksum",
+    ...     "SPARK-2222",
+    ...     "Compute a stable checksum",
+    ...     "In Progress",
+    ...     "Bug",
+    ... ))
+    === Verify JIRA matches PR #42 ===
+    PR title:  [SPARK-2222][SQL] Compute stable checksum
+    JIRA SPARK-2222: Compute a stable checksum
+      Status:  In Progress
+      Type:    Bug
+      Match:   1.00
+
+    A [FOLLOWUP] title scores low but is not warned about (only noted):
+
+    >>> print(format_jira_verification(
+    ...     42,
+    ...     "[SPARK-1][FOLLOWUP] Fix a typo",
+    ...     "SPARK-1",
+    ...     "Add a cache",
+    ...     "In Progress",
+    ...     "Bug",
+    ...     is_followup=True,
+    ... ))
+    === Verify JIRA matches PR #42 ===
+    PR title:  [SPARK-1][FOLLOWUP] Fix a typo
+    JIRA SPARK-1: Add a cache
+      Status:  In Progress
+      Type:    Bug
+      Match:   0.00   (FOLLOWUP: title intentionally differs, not checked)
+    """
+    status_warning = ""
+    if status in ("Resolved", "Closed"):
+        status_warning = "   <-- WARNING: already Resolved/Closed"
+        if use_color:
+            status_warning = red(status_warning)
+    score = title_similarity(pr_title, summary)
+    if is_followup:
+        # A follow-up title describes its own fix, not the original ticket, so 
a low
+        # score is expected and not a wrong-ticket signal. Note it, don't warn.
+        match_suffix = "   (FOLLOWUP: title intentionally differs, not 
checked)"
+    elif score < SIMILARITY_WARN_THRESHOLD:
+        match_suffix = "   <-- WARNING: low title similarity, wrong ticket?"
+        if use_color:
+            match_suffix = red(match_suffix)
+    else:
+        match_suffix = ""
+    return "\n".join(
+        [
+            "=== Verify JIRA matches PR #%s ===" % pr_num,
+            "PR title:  %s" % pr_title,
+            "JIRA %s: %s" % (jira_id, summary),
+            "  Status:  %s%s" % (status, status_warning),
+            "  Type:    %s" % issuetype,
+            "  Match:   %.2f%s" % (score, match_suffix),
+        ]
+    )
+
+
 def print_jira_issue_summary(issue):
     summary = "Summary\t\t%s\n" % issue.fields.summary
     assignee = issue.fields.assignee
@@ -1516,6 +1695,7 @@ def main():
     # discovering them one-by-one across repeated merge attempts.
     blocking_issue_types = {"Epic", "Umbrella"}
     blockers = []
+    linked_issues = []
     for jira_id in jira_ids:
         try:
             issue = asf_jira.issue(jira_id)
@@ -1523,6 +1703,7 @@ def main():
             print_error("Unable to fetch summary of %s" % jira_id)
             continue
         print_jira_issue_summary(issue)
+        linked_issues.append((jira_id, issue))
         issue_type = issue.fields.issuetype.name
         if issue_type in blocking_issue_types:
             blockers.append((jira_id, issue_type))
@@ -1535,6 +1716,34 @@ def main():
             "the Sub-task(s) instead." % (pr_num, ids_str, ids_str)
         )
 
+    # Confirm each linked JIRA actually matches this PR before merging. A 
committer can
+    # reference the wrong ticket -- e.g. mistaking a GitHub PR number in the 
body
+    # ("Closes #NNNNN") for a SPARK JIRA id -- which resolves an unrelated 
ticket on merge.
+    # Show the PR title next to each ticket's summary, warn when a ticket is 
already
+    # Resolved/Closed (a fresh merge should target an open one), and require 
confirmation.
+    # For [FOLLOWUP] PRs the title describes the follow-up fix rather than the 
ticket, so
+    # its low similarity to the JIRA summary is expected and the warning is 
suppressed.
+    is_followup = "FOLLOWUP" in title_components
+    for jira_id, issue in linked_issues:
+        print()
+        print(
+            format_jira_verification(
+                pr_num,
+                title,
+                jira_id,
+                issue.fields.summary,
+                issue.fields.status.name,
+                issue.fields.issuetype.name,
+                use_color=True,
+                is_followup=is_followup,
+            )
+        )
+        if get_input("Does %s match this PR? (y/N): " % jira_id, ["y", "n", 
""]) != "y":
+            fail(
+                "Aborting: %s does not match PR #%s. Fix the PR title to 
reference the "
+                "correct JIRA ticket and retry." % (jira_id, pr_num)
+            )
+
     print("\n=== Pull Request #%s ===" % pr_num)
     print("title\t%s\nsource\t%s\ntarget\t%s\nurl\t%s" % (title, pr_repo_desc, 
target_ref, url))
     continue_maybe("Proceed with merging pull request #%s?" % pr_num)


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

Reply via email to