dongjoon-hyun commented on code in PR #60031:
URL: https://github.com/apache/spark/pull/60031#discussion_r4470227639
##########
dev/merge_spark_pr.py:
##########
@@ -2446,6 +2636,79 @@ def prompt_for_components():
return components
+def prepare_merge_title(raw_title: str) -> tuple[str, list[str], bool]:
+ """Return ``(title, title_components, is_revert)`` for the title of the PR
to merge.
+
+ Surrounding whitespace is stripped. A ``Revert "..."`` / ``Reapply "..."``
title is
+ otherwise kept as is: the wrapped title is an existing commit subject,
which may
+ predate the current title rules, so it is not parsed and has no
components. It is
+ rejected when the innermost title still starts with ``Revert`` or
``Reapply``, e.g.
+ with a quote dropped: that wrapper would not be counted when deciding the
direction
+ (see :func:`_is_net_revert`), and its JIRA id and skip tags would not be
found
+ either. Any other title is parsed with :meth:`Title.parse`, which
+ fails on a malformed title, and its component tags are normalized, with a
prompt for
+ a primary component when there is none. A title that changes is printed.
+ """
+ title = raw_title.strip()
+ inner, words = _unwrap_wrappers(title)
+ if words:
+ if _WRAPPER_WORD_RE.match(inner):
Review Comment:
This rejects any title whose innermost text merely starts with the word
`Revert` or `Reapply`, not only a wrapper with a dropped quote. For example, a
git default revert of a commit titled `Revert to using comma-delimited split`
gives `Revert "Revert to using comma-delimited split"`, which now fails with no
way around it. The retitle suggested by the error message (`Reapply "..."`)
would actually flip the direction to resolve in that case. The PR description
counts 56 existing subjects that would be rejected this way.
Could this be narrowed to the dropped-quote case, or turned into a warning
with a confirmation prompt (`continue_maybe`) instead of `fail()`?
##########
dev/merge_spark_pr.py:
##########
@@ -2446,6 +2636,79 @@ def prompt_for_components():
return components
+def prepare_merge_title(raw_title: str) -> tuple[str, list[str], bool]:
+ """Return ``(title, title_components, is_revert)`` for the title of the PR
to merge.
+
+ Surrounding whitespace is stripped. A ``Revert "..."`` / ``Reapply "..."``
title is
+ otherwise kept as is: the wrapped title is an existing commit subject,
which may
+ predate the current title rules, so it is not parsed and has no
components. It is
+ rejected when the innermost title still starts with ``Revert`` or
``Reapply``, e.g.
+ with a quote dropped: that wrapper would not be counted when deciding the
direction
+ (see :func:`_is_net_revert`), and its JIRA id and skip tags would not be
found
+ either. Any other title is parsed with :meth:`Title.parse`, which
+ fails on a malformed title, and its component tags are normalized, with a
prompt for
+ a primary component when there is none. A title that changes is printed.
+ """
+ title = raw_title.strip()
+ inner, words = _unwrap_wrappers(title)
Review Comment:
An empty innermost title is accepted: `Revert ""` and `Revert " "` both
return `('Revert ""', [], True)` (resp. `'Revert " "'`), so the merge goes
ahead with a meaningless subject and takes the revert path with the JIRA id
prompt. Should an empty `inner` be rejected here as well, e.g. `if words and
not inner: fail(...)`?
##########
dev/merge_spark_pr.py:
##########
@@ -3060,24 +3275,25 @@ def main():
... resolve_jira_issue=resolver,
continue_maybe=confirm),
... redirect_stdout(StringIO()),
... ):
- ... update_jira_for_pr("123", title, branches, list(components),
is_revert=is_revert)
+ ... update_jira_for_pr("123", title, branches, list(components),
+ ...
is_revert=_is_net_revert(_unwrap_wrappers(title)[1]))
Review Comment:
This helper recomputes `is_revert` from the title, so the doctest exercises
`update_jira_for_pr()` but not the path that `main()` actually uses
(`prepare_merge_title()` -> `is_revert_pr` -> `update_jira_for_pr(...,
is_revert=is_revert_pr)`). Passing the wrong variable at one of the call sites
in `main()` would still pass these tests. Using `prepare_merge_title(title)[2]`
here would at least tie the test to the production computation (or this becomes
moot with the suggestion on `update_jira_for_pr`'s signature).
##########
dev/merge_spark_pr.py:
##########
@@ -1960,41 +2008,124 @@ def resolve_jira_issues(jira_ids, merge_branches,
comment, title_components=()):
)
-def update_jira_for_pr(pr_num, title, merge_branches, title_components,
is_revert=False):
- if jira_disabled:
- # JIRA was disabled at startup (unreachable); nothing to update.
- print()
- print_error(
- f"JIRA is disabled; skipping JIRA update for PR #{pr_num}. "
- f"Reconcile the ticket(s) manually."
- )
- return
+def jira_skip_tags(title):
+ """The title tags that make :func:`update_jira_for_pr` skip the JIRA
update.
+ >>> jira_skip_tags('Revert "[MINOR][SQL] Fix"')
+ ['MINOR']
+ >>> jira_skip_tags('[SPARK-1234][SQL][FOLLOWUP] Fix')
+ ['FOLLOWUP']
+ >>> jira_skip_tags('[SPARK-1234][SQL] Fix')
+ []
+ """
skip_jira_title_tags = ("MINOR", "TRIVIAL", "FOLLOWUP")
- jira_ids = leading_jira_ids(title)
- # Revert/Reapply PRs keep their title verbatim, so the tags are read
inside the wrappers,
+ # Revert/Reapply PRs keep their title as is, so the tags are read inside
the wrappers,
# without Title.parse, which rejects some older titles such as
[SPARK-N][FOLLOWUP][MINOR].
tags = leading_title_tags(title)
- if jira_ids:
+ if leading_jira_ids(title):
# Title.parse rejects an id next to [MINOR] or [TRIVIAL], and
prompt_for_components()
# rejects both markers, so only a subject that predates the rule or
was not merged with
# this script has both. Such a commit was normally merged as the fix
for that id, so
# the id is updated.
tags -= _LEADING_TAGS
- skipped = [tag for tag in skip_jira_title_tags if tag in tags]
+ return [tag for tag in skip_jira_title_tags if tag in tags]
+
+
+def _bracketed(tags):
+ """Format tags as in a title, e.g. ``[MINOR], [FOLLOWUP]``, for JIRA
messages."""
+ return ", ".join("[%s]" % tag for tag in tags)
+
+
+def jira_action(title, is_revert):
+ """Decide what :func:`update_jira_for_pr` does, as ``(action, detail)``.
+
+ ``action`` is ``"disabled"`` when JIRA is unreachable, ``"skip"`` when the
title has
+ skip tags (``detail`` lists them), and otherwise ``"revert"`` or
``"resolve"``, with
+ the leading JIRA ids as ``detail``; with no id, the committer enters one
at the
+ prompt. :func:`describe_jira_action` shows the same decision before the
push.
+
+ >>> jira_action('[SPARK-1234][SPARK-5678][SQL] Fix', False)
+ ('resolve', ['SPARK-1234', 'SPARK-5678'])
+ >>> jira_action('Revert "Fix"', True)
+ ('revert', [])
+ >>> jira_action('Revert "[MINOR][SQL] Fix"', True)
+ ('skip', ['MINOR'])
+ >>> from unittest.mock import patch
+ >>> with patch.dict(jira_action.__globals__, jira_disabled=True):
+ ... jira_action('[SPARK-1234][SQL] Fix', False)
+ ('disabled', [])
+ """
+ if jira_disabled:
+ return "disabled", []
+ skipped = jira_skip_tags(title)
if skipped:
+ return "skip", skipped
+ return ("revert" if is_revert else "resolve"), leading_jira_ids(title)
Review Comment:
Minor: `jira_skip_tags()` already calls `leading_title_tags()` and
`leading_jira_ids()`, and `leading_jira_ids(title)` is called again here.
`jira_action()` itself runs from both `describe_jira_action()` and
`update_jira_for_pr()`, so the same title is unwrapped and parsed several
times. It is cheap, but reusing the ids, or computing the decision once and
passing it along, would be simpler.
##########
dev/merge_spark_pr.py:
##########
@@ -1255,10 +1263,46 @@ def format_jira_verification(
Status: In Progress
Type: Bug
Match: 0.00 (FOLLOWUP: title intentionally differs, not checked)
+
+ A Revert/Reapply title normally targets a ticket resolved as Fixed, so
that is not
+ warned about, while another resolution still is:
+
+ >>> print(format_jira_verification(
+ ... 42,
+ ... 'Revert "[SPARK-1][SQL] Add a cache"',
+ ... "SPARK-1",
+ ... "Add a cache",
+ ... "Resolved",
+ ... "Bug",
+ ... resolution="Fixed",
+ ... is_wrapped=True,
+ ... ))
+ === Verify JIRA matches PR #42 ===
+ PR title: Revert "[SPARK-1][SQL] Add a cache"
+ JIRA SPARK-1: Add a cache
+ Status: Resolved (Fixed)
+ Type: Bug
+ Match: 0.50
+ >>> print(format_jira_verification(
+ ... 42,
+ ... 'Revert "[SPARK-1][SQL] Add a cache"',
+ ... "SPARK-1",
+ ... "Add a cache",
+ ... "Resolved",
+ ... "Bug",
+ ... resolution="Duplicate",
+ ... is_wrapped=True,
+ ... ))
+ === Verify JIRA matches PR #42 ===
+ PR title: Revert "[SPARK-1][SQL] Add a cache"
+ JIRA SPARK-1: Add a cache
+ Status: Resolved (Duplicate) <-- WARNING: already Resolved/Closed
+ Type: Bug
+ Match: 0.50
"""
status_warning = ""
resolution_suffix = " (%s)" % resolution if resolution else ""
- if status in ("Resolved", "Closed"):
+ if status in ("Resolved", "Closed") and not (is_wrapped and resolution ==
"Fixed"):
Review Comment:
The suppression of the Resolved/Closed warning for wrapped titles here, and
the new `jira` summary line in `main()`, look independent of the parity fix in
SPARK-60814. Would you consider moving them to a separate JIRA/PR? Narrowing a
pre-merge safety warning would be easier to review, and to revert if needed, on
its own.
##########
dev/merge_spark_pr.py:
##########
@@ -2283,16 +2418,35 @@ def __str__(self) -> str:
return parts + (" " if parts else "") + self.text
+def _unwrap_wrappers(title: str) -> tuple[str, list[str]]:
+ """Return the innermost title and the wrapper words
(``Revert``/``Reapply``), outermost first.
+
+ See :func:`_unwrap_title` for how the wrappers are stripped.
Review Comment:
The implementation now lives in `_unwrap_wrappers()`, but this docstring
points to `_unwrap_title()`, which is now a one-line delegate, for how the
wrappers are stripped. Moving that description here and having
`_unwrap_title()` refer to this function would keep the docs next to the code.
##########
dev/merge_spark_pr.py:
##########
@@ -1960,41 +2008,124 @@ def resolve_jira_issues(jira_ids, merge_branches,
comment, title_components=()):
)
-def update_jira_for_pr(pr_num, title, merge_branches, title_components,
is_revert=False):
- if jira_disabled:
- # JIRA was disabled at startup (unreachable); nothing to update.
- print()
- print_error(
- f"JIRA is disabled; skipping JIRA update for PR #{pr_num}. "
- f"Reconcile the ticket(s) manually."
- )
- return
+def jira_skip_tags(title):
+ """The title tags that make :func:`update_jira_for_pr` skip the JIRA
update.
+ >>> jira_skip_tags('Revert "[MINOR][SQL] Fix"')
+ ['MINOR']
+ >>> jira_skip_tags('[SPARK-1234][SQL][FOLLOWUP] Fix')
+ ['FOLLOWUP']
+ >>> jira_skip_tags('[SPARK-1234][SQL] Fix')
+ []
+ """
skip_jira_title_tags = ("MINOR", "TRIVIAL", "FOLLOWUP")
- jira_ids = leading_jira_ids(title)
- # Revert/Reapply PRs keep their title verbatim, so the tags are read
inside the wrappers,
+ # Revert/Reapply PRs keep their title as is, so the tags are read inside
the wrappers,
# without Title.parse, which rejects some older titles such as
[SPARK-N][FOLLOWUP][MINOR].
tags = leading_title_tags(title)
- if jira_ids:
+ if leading_jira_ids(title):
# Title.parse rejects an id next to [MINOR] or [TRIVIAL], and
prompt_for_components()
# rejects both markers, so only a subject that predates the rule or
was not merged with
# this script has both. Such a commit was normally merged as the fix
for that id, so
# the id is updated.
tags -= _LEADING_TAGS
- skipped = [tag for tag in skip_jira_title_tags if tag in tags]
+ return [tag for tag in skip_jira_title_tags if tag in tags]
+
+
+def _bracketed(tags):
+ """Format tags as in a title, e.g. ``[MINOR], [FOLLOWUP]``, for JIRA
messages."""
+ return ", ".join("[%s]" % tag for tag in tags)
+
+
+def jira_action(title, is_revert):
+ """Decide what :func:`update_jira_for_pr` does, as ``(action, detail)``.
+
+ ``action`` is ``"disabled"`` when JIRA is unreachable, ``"skip"`` when the
title has
+ skip tags (``detail`` lists them), and otherwise ``"revert"`` or
``"resolve"``, with
+ the leading JIRA ids as ``detail``; with no id, the committer enters one
at the
+ prompt. :func:`describe_jira_action` shows the same decision before the
push.
+
+ >>> jira_action('[SPARK-1234][SPARK-5678][SQL] Fix', False)
+ ('resolve', ['SPARK-1234', 'SPARK-5678'])
+ >>> jira_action('Revert "Fix"', True)
+ ('revert', [])
+ >>> jira_action('Revert "[MINOR][SQL] Fix"', True)
+ ('skip', ['MINOR'])
+ >>> from unittest.mock import patch
+ >>> with patch.dict(jira_action.__globals__, jira_disabled=True):
+ ... jira_action('[SPARK-1234][SQL] Fix', False)
+ ('disabled', [])
+ """
+ if jira_disabled:
+ return "disabled", []
+ skipped = jira_skip_tags(title)
if skipped:
+ return "skip", skipped
+ return ("revert" if is_revert else "resolve"), leading_jira_ids(title)
+
+
+def describe_jira_action(title, is_revert):
+ """Say what :func:`update_jira_for_pr` will do (see :func:`jira_action`),
before a push.
+
+ Both JIRA paths leave a ticket that is resolved other than as Fixed as it
is (see
+ :func:`get_jira_issue`), which the summary cannot tell without fetching
the ticket,
+ so it says so.
+
+ >>> print(describe_jira_action('[SPARK-1234][SQL] Fix', False))
+ ... # doctest: +NORMALIZE_WHITESPACE
+ resolve SPARK-1234 (or add fix versions if already resolved as Fixed;
+ a ticket resolved other than as Fixed is left as is)
+ >>> print(describe_jira_action('Revert "[SPARK-1234][SQL] Fix"', True))
+ ... # doctest: +NORMALIZE_WHITESPACE
+ revert SPARK-1234 (remove fix versions and offer to reopen if resolved;
+ a ticket resolved other than as Fixed is left as is)
+ >>> print(describe_jira_action('Revert "Fix"', True))
+ ... # doctest: +NORMALIZE_WHITESPACE
+ revert a ticket entered at the prompt (remove fix versions and offer to
reopen if resolved;
+ a ticket resolved other than as Fixed is left as is)
+ >>> describe_jira_action('Revert "[MINOR][SQL] Fix"', True)
+ 'skip (title has [MINOR])'
+ >>> from unittest.mock import patch
+ >>> with patch.dict(describe_jira_action.__globals__, jira_disabled=True):
+ ... describe_jira_action('[SPARK-1234][SQL] Fix', False)
+ 'skip (JIRA is unreachable)'
+ """
+ action, detail = jira_action(title, is_revert)
+ if action == "disabled":
+ return "skip (JIRA is unreachable)"
+ if action == "skip":
+ return "skip (title has %s)" % _bracketed(detail)
+ ids = ", ".join(detail) or "a ticket entered at the prompt"
+ other = "a ticket resolved other than as Fixed is left as is"
+ if action == "revert":
+ return "revert %s (remove fix versions and offer to reopen if
resolved; %s)" % (ids, other)
+ return "resolve %s (or add fix versions if already resolved as Fixed; %s)"
% (ids, other)
+
+
+def update_jira_for_pr(pr_num, title, merge_branches, title_components, *,
is_revert):
Review Comment:
`is_revert` is fully determined by `title`: it is
`_is_net_revert(_unwrap_wrappers(title)[1])`, and `prepare_merge_title()` sets
it to `False` for an unwrapped title, which is the same as
`_is_net_revert([])`. Yet it is threaded from `prepare_merge_title()` through
`main()` into `describe_jira_action()` and `update_jira_for_pr()` as a required
keyword argument.
Computing it inside `jira_action()` would drop the parameter, remove any
chance of the call sites disagreeing (including a missing argument surfacing as
a `TypeError` only after the push, as the PR description mentions), and the
doctest helper would not need to duplicate the computation.
##########
dev/merge_spark_pr.py:
##########
@@ -2137,8 +2268,12 @@ def normalize(cls, token):
_LEADING_TAGS = frozenset({"MINOR", "TRIVIAL"})
# A SPARK id that opens a title without brackets, e.g. the old subject
"SPARK-49004".
_BARE_SPARK_ID_RE = re.compile(r"SPARK-[0-9]+\b", re.IGNORECASE | re.ASCII)
-# A Revert "..." / Reapply "..." wrapper, which main() keeps verbatim in the
PR title.
-_WRAPPER_RE = re.compile(r'(?:Revert|Reapply) "(.*)"', re.DOTALL)
+# A Revert "..." / Reapply "..." wrapper, which prepare_merge_title() keeps in
the PR title.
+_WRAPPER_RE = re.compile(r'(Revert|Reapply) "(.*)"', re.DOTALL)
+# Revert or Reapply, or a form of either such as Reverting, still opening a
title after
+# unwrapping, e.g. 'Revert [SPARK-N] ...'. Case is ignored, as older subjects
include
+# 'revert [SPARK-N] ...' and 'Reverting ...'.
+_WRAPPER_WORD_RE = re.compile(r"Revert|Reapply", re.IGNORECASE)
Review Comment:
Since `_WRAPPER_WORD_RE.match` is a prefix match on `Revert|Reapply`,
`Reverted`, `Reverting` and `Reapplying` are caught, but `Reapplied`,
`Reapplies` and `Re-apply` are not. For example, `Revert "Reapplied [SPARK-1]
x"` passes the check and `jira_action()` returns `('revert', [])`, i.e. the
revert path without a JIRA id, which is what this check is meant to prevent.
Something like `Revert|Re-?appl` would handle these forms consistently.
##########
dev/merge_spark_pr.py:
##########
@@ -2658,61 +2921,9 @@ def main():
if pr.get("draft", False) or "[WIP]" in title or "[DO-NOT-MERGE]" in title:
fail("Cannot merge a draft PR #%s: %s" % (pr_num, title))
- # e.g. 'Revert "[SPARK-56357][BUILD] Upgrade sbt to 1.12.8"'
- is_revert_pr = title.startswith('Revert "') and title.endswith('"')
- # e.g. 'Reapply "[SPARK-56357][BUILD] Upgrade sbt to 1.12.8"'
- is_reapply_pr = title.startswith('Reapply "') and title.endswith('"')
-
- # Normalized PR-title component tags, used later to reconcile JIRA
components. Empty for
- # Revert/Reapply PRs, whose titles are kept verbatim and not parsed for
components.
- title_components: List[str] = []
-
- # Revert and Reapply PRs keep their title verbatim.
- if not (is_revert_pr or is_reapply_pr):
- # Parse; fail on a malformed title.
- try:
- parsed = Title.parse(title)
- except ValueError as e:
- fail("Malformed PR title: %s" % e)
-
- # Normalize component tags via the registry and track primary.
- components = []
- has_primary = False
- for tag in parsed.components:
- c = Component.find(tag)
- if c is not None and c.primary:
- has_primary = True
- components.append(Component.normalize(tag))
- if not has_primary:
- new_tags = prompt_for_components()
- components = new_tags + components
-
- # Deduplicate tags in insertion order.
- components = list(dict.fromkeys(components))
-
- # Move version tags (e.g. [4.X], [4.2]) to the head of components.
- versions = [t for t in components if _VERSION_TAG_RE.match(t)]
- if versions:
- others = [t for t in components if not _VERSION_TAG_RE.match(t)]
- components = versions + others
-
- # Move FOLLOWUP to the last tag.
- non_followup = [t for t in components if t != "FOLLOWUP"]
- if len(non_followup) < len(components):
- components = non_followup + ["FOLLOWUP"]
-
- # Warn about tags that are neither known components nor version tags.
- unknown = [
- t for t in components if Component.find(t) is None and not
_VERSION_TAG_RE.match(t)
- ]
- if unknown:
- print_error("Title has unknown tag(s): %s" % ", ".join("[%s]" % t
for t in unknown))
-
- parsed.components = components
- title_components = list(parsed.components)
- title = str(parsed)
- if title != pr["title"]:
- print("Normalized title: %s" % title)
+ # title_components are the normalized PR-title component tags, used later
to reconcile
+ # JIRA components.
+ title, title_components, is_revert_pr = prepare_merge_title(title)
Review Comment:
`prepare_merge_title()` runs before the already-merged check, so on a
backport both the direction and the new unbalanced check come from the current
GitHub PR title, not from the merge commit (`message` from
`find_merge_commit()`). If the title was edited after the merge, or a PR merged
with the previous script has a title that is now considered unbalanced, the
backport run either aborts here or updates JIRA in the opposite direction of
what was actually committed (removing fix versions vs. resolving).
Would it make sense to derive the direction from the merge commit subject in
the backport path, or at least not `fail()` there?
##########
dev/merge_spark_pr.py:
##########
@@ -1347,11 +1391,14 @@ def check_linked_jira_issues(pr_num, title):
# 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.
+ # Resolved/Closed (a fresh merge should target an open one, while a
Revert/Reapply PR
+ # normally targets one resolved as Fixed, see format_jira_verification),
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.
# The tag is read inside Revert/Reapply wrappers too.
is_followup = "FOLLOWUP" in leading_title_tags(title)
+ is_wrapped = bool(_unwrap_wrappers(title)[1])
Review Comment:
`prepare_merge_title()` has already computed the wrapper words, and this
unwraps the title again. If the wrapper rules change later, the warning
suppression here and the direction decision there could drift apart. Maybe
`prepare_merge_title()` could return `is_wrapped` (or the words) and pass it in?
--
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]