From 2c7b351b46226a4dccd0b40f764a68262bb8c797 Mon Sep 17 00:00:00 2001 From: Gengliang Wang Date: Wed, 8 Jul 2026 18:01:29 +0000 Subject: [PATCH 1/4] [SPARK-58045][INFRA] Verify the linked JIRA matches the PR in merge_spark_pr.py Add a verification step in merge_spark_pr.py that, after the committer enters the PR number, shows the PR title next to each linked JIRA's summary/status/type, warns in red when a ticket is already Resolved/Closed, and requires an explicit y/N confirmation before merging. This guards against merging a PR against the wrong ticket (e.g. mistaking a GitHub PR number for a SPARK JIRA id). Co-authored-by: Isaac --- dev/merge_spark_pr.py | 88 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py index e3f542eb1ac67..6e9b33e369378 100755 --- a/dev/merge_spark_pr.py +++ b/dev/merge_spark_pr.py @@ -320,8 +320,12 @@ def keep(item): 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,62 @@ def cherry_pick(pr_num, merge_hash, default_branch, branch_names, target_ref, al return [_do_cherry_pick(pr_num, merge_hash, pick_ref)] +def format_jira_verification( + pr_num, pr_title, jira_id, summary, status, issuetype, use_color=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, and flags a ticket that is already Resolved/Closed + (a fresh merge should target an open ticket). Pure formatting so it is covered + by the inline doctests. ``use_color`` wraps the Resolved/Closed warning in the + same red as ``print_error``; it is left off in the doctests so the expected + output stays plain text. + + >>> print(format_jira_verification( + ... 42, + ... "[SPARK-2222][SQL] Feature B", + ... "SPARK-1111", + ... "Feature A (unrelated)", + ... "Resolved", + ... "Improvement", + ... )) + === Verify JIRA matches PR #42 === + PR title: [SPARK-2222][SQL] Feature B + JIRA SPARK-1111: Feature A (unrelated) + Status: Resolved <-- WARNING: already Resolved/Closed + Type: Improvement + + >>> print(format_jira_verification( + ... 42, + ... "[SPARK-2222][SQL] Feature B", + ... "SPARK-2222", + ... "Feature B", + ... "In Progress", + ... "Bug", + ... )) + === Verify JIRA matches PR #42 === + PR title: [SPARK-2222][SQL] Feature B + JIRA SPARK-2222: Feature B + Status: In Progress + Type: Bug + """ + warning = "" + if status in ("Resolved", "Closed"): + warning = " <-- WARNING: already Resolved/Closed" + if use_color: + warning = red(warning) + 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, warning), + " Type: %s" % issuetype, + ] + ) + + def print_jira_issue_summary(issue): summary = "Summary\t\t%s\n" % issue.fields.summary assignee = issue.fields.assignee @@ -1516,6 +1576,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 +1584,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 +1597,30 @@ 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 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, + ) + ) + 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) From 9f951401ddf30b4a1e9b525c927da5ddf551180a Mon Sep 17 00:00:00 2001 From: Gengliang Wang Date: Wed, 8 Jul 2026 18:33:23 +0000 Subject: [PATCH 2/4] [SPARK-58045][INFRA][FOLLOWUP] Warn on low PR/JIRA title similarity Add a title-similarity check to the JIRA verification block in merge_spark_pr.py. For each linked JIRA, compute a Jaccard word-overlap score between the PR title and the JIRA summary (component/version tags and stopwords stripped) and print it on a new "Match:" line, with a red warning when the score falls below a threshold (0.2) -- a strong signal the PR references the wrong ticket. The Epic/Umbrella case is already guarded by the existing hard-fail before this block, so no separate umbrella warning is added. Co-authored-by: Isaac --- dev/merge_spark_pr.py | 110 +++++++++++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 17 deletions(-) diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py index 6e9b33e369378..efa9b8ee128a8 100755 --- a/dev/merge_spark_pr.py +++ b/dev/merge_spark_pr.py @@ -690,58 +690,134 @@ 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 + """ + + 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 ): """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, and flags a ticket that is already Resolved/Closed - (a fresh merge should target an open ticket). Pure formatting so it is covered - by the inline doctests. ``use_color`` wraps the Resolved/Closed warning in the - same red as ``print_error``; it is left off in the doctests so the expected - output stays plain text. + 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. >>> print(format_jira_verification( ... 42, - ... "[SPARK-2222][SQL] Feature B", + ... "[SPARK-2222][SQL] Compute stable checksum", ... "SPARK-1111", - ... "Feature A (unrelated)", + ... "Refactor the logging backend", ... "Resolved", ... "Improvement", ... )) === Verify JIRA matches PR #42 === - PR title: [SPARK-2222][SQL] Feature B - JIRA SPARK-1111: Feature A (unrelated) + 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] Feature B", + ... "[SPARK-2222][SQL] Compute stable checksum", ... "SPARK-2222", - ... "Feature B", + ... "Compute a stable checksum", ... "In Progress", ... "Bug", ... )) === Verify JIRA matches PR #42 === - PR title: [SPARK-2222][SQL] Feature B - JIRA SPARK-2222: Feature B + PR title: [SPARK-2222][SQL] Compute stable checksum + JIRA SPARK-2222: Compute a stable checksum Status: In Progress Type: Bug + Match: 1.00 """ - warning = "" + status_warning = "" if status in ("Resolved", "Closed"): - warning = " <-- WARNING: already Resolved/Closed" + status_warning = " <-- WARNING: already Resolved/Closed" + if use_color: + status_warning = red(status_warning) + score = title_similarity(pr_title, summary) + match_warning = "" + if score < SIMILARITY_WARN_THRESHOLD: + match_warning = " <-- WARNING: low title similarity, wrong ticket?" if use_color: - warning = red(warning) + match_warning = red(match_warning) 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, warning), + " Status: %s%s" % (status, status_warning), " Type: %s" % issuetype, + " Match: %.2f%s" % (score, match_warning), ] ) From 973733bff4e7fd462eee399ab1bdd235830dae42 Mon Sep 17 00:00:00 2001 From: Gengliang Wang Date: Wed, 8 Jul 2026 22:04:14 +0000 Subject: [PATCH 3/4] [SPARK-58045][INFRA][FOLLOWUP] Add doctests for [FOLLOWUP] and Revert title patterns Address review feedback: add title_similarity doctests covering the [FOLLOWUP] tag (stripped like any bracket tag, so a matching follow-up still scores 1.0) and the Revert "..." title pattern (kept verbatim, so the extra word lowers the score but a genuine match still scores well above the warning threshold). Co-authored-by: Isaac --- dev/merge_spark_pr.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py index efa9b8ee128a8..69d246f30b934 100755 --- a/dev/merge_spark_pr.py +++ b/dev/merge_spark_pr.py @@ -742,6 +742,18 @@ def title_similarity(pr_title, summary): 0.0 >>> round(title_similarity("[SPARK-1] Ceil and floor overflow", "Handle floor overflow"), 2) 0.5 + + A [FOLLOWUP] tag is stripped like any other bracket tag, so a follow-up whose + title still matches the ticket scores the same as the untagged title: + + >>> title_similarity("[SPARK-1][FOLLOWUP] Add a cache", "Add a cache") + 1.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): From 9f74fcbda1e6e16ffe600ed7549676017c4cc5d5 Mon Sep 17 00:00:00 2001 From: Gengliang Wang Date: Wed, 8 Jul 2026 22:39:14 +0000 Subject: [PATCH 4/4] [SPARK-58045][INFRA][FOLLOWUP] Suppress low-similarity warning for [FOLLOWUP] PRs A follow-up PR title describes the fix it adds, not the original ticket, so it legitimately scores low against the JIRA summary even when it references the right ticket -- firing the low-similarity warning on the normal [FOLLOWUP] workflow and training committers to click through it. Suppress the warning when the title carries a [FOLLOWUP] tag: still show the Match score, but with an explanatory note instead of a warning. The Resolved/Closed warning and the warning for non-follow-up PRs are unchanged. Fix the misleading doctest that implied follow-up titles match the ticket. Co-authored-by: Isaac --- dev/merge_spark_pr.py | 55 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py index 69d246f30b934..09598adc86110 100755 --- a/dev/merge_spark_pr.py +++ b/dev/merge_spark_pr.py @@ -743,11 +743,13 @@ def title_similarity(pr_title, summary): >>> round(title_similarity("[SPARK-1] Ceil and floor overflow", "Handle floor overflow"), 2) 0.5 - A [FOLLOWUP] tag is stripped like any other bracket tag, so a follow-up whose - title still matches the ticket scores the same as the untagged title: + 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] Add a cache", "Add a cache") - 1.0 + >>> 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: @@ -769,7 +771,7 @@ def tokens(text): def format_jira_verification( - pr_num, pr_title, jira_id, summary, status, issuetype, use_color=False + 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. @@ -781,6 +783,12 @@ def format_jira_verification( 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", @@ -810,6 +818,24 @@ def format_jira_verification( 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"): @@ -817,11 +843,16 @@ def format_jira_verification( if use_color: status_warning = red(status_warning) score = title_similarity(pr_title, summary) - match_warning = "" - if score < SIMILARITY_WARN_THRESHOLD: - match_warning = " <-- WARNING: low title similarity, wrong ticket?" + 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_warning = red(match_warning) + match_suffix = red(match_suffix) + else: + match_suffix = "" return "\n".join( [ "=== Verify JIRA matches PR #%s ===" % pr_num, @@ -829,7 +860,7 @@ def format_jira_verification( "JIRA %s: %s" % (jira_id, summary), " Status: %s%s" % (status, status_warning), " Type: %s" % issuetype, - " Match: %.2f%s" % (score, match_warning), + " Match: %.2f%s" % (score, match_suffix), ] ) @@ -1690,6 +1721,9 @@ def main(): # ("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( @@ -1701,6 +1735,7 @@ def main(): 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":