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

cloud-fan 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 b30affecbd40 [SPARK-56979][INFRA] Require COMPONENT tag in PR title at 
merge time
b30affecbd40 is described below

commit b30affecbd401f3d8d2be5cf736b30f47643c396
Author: Ruifeng Zheng <[email protected]>
AuthorDate: Tue May 26 00:57:50 2026 +0800

    [SPARK-56979][INFRA] Require COMPONENT tag in PR title at merge time
    
    ### What changes were proposed in this pull request?
    
    This PR rewrites the PR title processing in `dev/merge_spark_pr.py`:
    
    **New `Component` registry** — a typed `Component` class and a `COMPONENTS` 
tuple listing all recognized Spark JIRA components with their canonical tags, 
aliases, and a `primary` flag. `primary=True` means the tag alone satisfies the 
merge-time requirement; non-primary tags (e.g. `[TEST]`, `[SHUFFLE]`) must be 
paired with a primary one. The primary set covers the main subsystems: `BUILD`, 
`CONNECT`, `CORE`, `DOC`, `DOCKER`, `GRAPHX`, `INFRA`, `K8S`, `ML`, `MLLIB`, 
`PS`, `PYTHON`, `R`, [...]
    
    **New `Title` parser** — a `Title` class with `leading` (SPARK-NNNNN / 
MINOR / TRIVIAL), `components`, and `text` fields. `Title.parse()` is strict:
    - the title must open with a leading tag;
    - tags may appear in any order and with arbitrary surrounding whitespace;
    - bracket-tag characters include letters, digits, `_`, `-`, and `.` (so 
version tags like `[4.X]` and `[3.5]` are recognized);
    - SPARK-NNNNN IDs must all precede any component tags;
    - `[MINOR]` and `[TRIVIAL]` cannot coexist with each other or with a 
SPARK-ID.
    
    Malformed titles raise `ValueError`.
    
    **Replaced `standardize_jira_ref`** — the old lenient regex rewriter that 
tolerated bare `SPARK 1234` refs, `[Project Infra]` multi-word tags, etc. is 
removed. Titles must now be well-formed before reaching the merge step.
    
    **New title pipeline in `main()`**:
    1. Hard-fail on `[WIP]` or `[DO-NOT-MERGE]` (previously just a soft prompt).
    2. `Revert "..."` and `Reapply "..."` titles are kept verbatim.
    3. `Title.parse()` — fail with a clear message if malformed.
    4. Normalize each component tag via the registry (e.g. `PYSPARK`→`PYTHON`, 
`DOCS`→`DOC`, `FOLLOW-UP`→`FOLLOWUP`, `TESTS`→`TEST`) and track whether any 
primary tag is present.
    5. If no primary component is present, prompt the committer to enter at 
least one; insert the entered tag(s) right after the leading refs.
    6. Deduplicate component tags in insertion order.
    7. Move backport version tags (`[4.X]`, `[4.2]`, `[3.5]`, ...) to the head 
of the component list.
    8. Move `[FOLLOWUP]` to the last position.
    9. Print a warning for any tag that is neither a known component nor a 
version tag.
    
    ### Why are the changes needed?
    
    Some PRs are merged without any `[COMPONENT]` tag (e.g. apache/spark#55866 
merged as `[SPARK-56853] Improve PATH Tests`), losing module attribution in the 
changelog. Others carry non-canonical tags or version tags in inconsistent 
positions, which makes changelog tooling unreliable.
    
    The old `standardize_jira_ref` tolerated very loose title formats but did 
not enforce component presence, leaving the gap. This PR closes it with a 
strict parser, registry-based normalization, and a prompt-based fallback when a 
primary component is missing.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The change only affects the committer-facing interactive merge tool 
(`dev/merge_spark_pr.py`).
    
    ### How was this patch tested?
    
    Doctests on `Title`, `Title.parse`, and the existing version-resolution 
helpers. All pass via:
    
    ```
    python3 -m doctest dev/merge_spark_pr.py
    ```
    
    The pipeline was also dry-run against the latest 1000 commits on `master` 
and 200 open PRs — see the comments on this PR for the full report.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
    
    Closes #56026 from zhengruifeng/SPARK-merge-script-require-component.
    
    Authored-by: Ruifeng Zheng <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 dev/merge_spark_pr.py | 397 +++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 314 insertions(+), 83 deletions(-)

diff --git a/dev/merge_spark_pr.py b/dev/merge_spark_pr.py
index 6e5da30f94b9..2bd96016ec30 100755
--- a/dev/merge_spark_pr.py
+++ b/dev/merge_spark_pr.py
@@ -43,6 +43,7 @@ import re
 import subprocess
 import sys
 import traceback
+from typing import List
 from urllib.request import urlopen
 from urllib.request import Request
 from urllib.error import HTTPError
@@ -775,77 +776,273 @@ def resolve_jira_issues(title, merge_branches, comment):
         resolve_jira_issue(merge_branches, comment, jira_id)
 
 
-def standardize_jira_ref(text):
-    """
-    Standardize the [SPARK-XXXXX] [MODULE] prefix
-    Converts "[SPARK-XXX][mllib] Issue", "[MLLib] SPARK-XXX. Issue" or "SPARK 
XXX [MLLIB]: Issue" to
-    "[SPARK-XXX][MLLIB] Issue"
-
-    >>> standardize_jira_ref(
-    ...     "[SPARK-5821] [SQL] ParquetRelation2 CTAS should check if delete 
is successful")
-    '[SPARK-5821][SQL] ParquetRelation2 CTAS should check if delete is 
successful'
-    >>> standardize_jira_ref(
-    ...     "[SPARK-4123][Project Infra][WIP]: Show new dependencies added in 
pull requests")
-    '[SPARK-4123][PROJECT INFRA][WIP] Show new dependencies added in pull 
requests'
-    >>> standardize_jira_ref("[MLlib] Spark  5954: Top by key")
-    '[SPARK-5954][MLLIB] Top by key'
-    >>> standardize_jira_ref("[SPARK-979] a LRU scheduler for load balancing 
in TaskSchedulerImpl")
-    '[SPARK-979] a LRU scheduler for load balancing in TaskSchedulerImpl'
-    >>> standardize_jira_ref(
-    ...     "SPARK-1094 Support MiMa for reporting binary compatibility across 
versions.")
-    '[SPARK-1094] Support MiMa for reporting binary compatibility across 
versions.'
-    >>> standardize_jira_ref("[WIP]  [SPARK-1146] Vagrant support for Spark")
-    '[SPARK-1146][WIP] Vagrant support for Spark'
-    >>> standardize_jira_ref(
-    ...     "SPARK-1032. If Yarn app fails before registering, app master 
stays aroun...")
-    '[SPARK-1032] If Yarn app fails before registering, app master stays 
aroun...'
-    >>> standardize_jira_ref(
-    ...     "[SPARK-6250][SPARK-6146][SPARK-5911][SQL] Types are now reserved 
words in DDL parser.")
-    '[SPARK-6250][SPARK-6146][SPARK-5911][SQL] Types are now reserved words in 
DDL parser.'
-    >>> standardize_jira_ref(
-    ...     'Revert "[SPARK-48591][PYTHON] Simplify the if-else branches with 
F.lit"')
-    'Revert "[SPARK-48591][PYTHON] Simplify the if-else branches with F.lit"'
-    >>> standardize_jira_ref("Additional information for users building from 
source code")
-    'Additional information for users building from source code'
-    """
-    jira_refs = []
-    components = []
+class Component:
+    """A Spark PR-title tag, paired with its canonical JIRA component name.
 
-    # If this is a Revert PR, no need to process any further
-    if text.startswith('Revert "') and text.endswith('"'):
-        return text
+    ``jira_name`` is the canonical name of the SPARK JIRA component (e.g.
+    "Documentation"); empty for status markers like [MINOR] that are not
+    JIRA components but are still recognized in PR titles.
 
-    # If the string is compliant, no need to process any further
-    if re.search(r"^\[SPARK-[0-9]{3,6}\](\[[A-Z0-9_\s,]+\] )+\S+", text):
-        return text
+    ``tag`` is the preferred PR-title abbreviation (uppercase, no brackets,
+    e.g. "DOC"). ``aliases`` lists other accepted spellings that resolve to
+    the same component (e.g. "DOCS", "DOCUMENTATION" -> "DOC").
 
-    # Extract JIRA ref(s):
-    pattern = re.compile(r"(SPARK[-\s]*[0-9]{3,6})+", re.IGNORECASE)
-    for ref in pattern.findall(text):
-        # Add brackets, replace spaces with a dash, & convert to uppercase
-        jira_refs.append("[" + re.sub(r"\s+", "-", ref.upper()) + "]")
-        text = text.replace(ref, "")
+    ``primary`` marks components whose presence alone satisfies the merge-time
+    requirement. Non-primary JIRA components (e.g. [TEST], [SHUFFLE], [DEPLOY])
+    remain recognized — they normalize and pass through validation — but
+    they must be paired with a primary tag (e.g. [SQL][TEST]). Status
+    markers are never primary. [WIP] is intentionally absent from the
+    registry: a WIP PR should be aborted at the earlier WIP warning, not
+    merged.
+    """
 
-    # Extract spark component(s):
-    # Look for alphanumeric chars, spaces, dashes, periods, and/or commas
-    pattern = re.compile(r"(\[[\w\s,.-]+\])", re.IGNORECASE)
-    for component in pattern.findall(text):
-        components.append(component.upper())
-        text = text.replace(component, "")
+    def __init__(self, tag, aliases=(), primary=False, jira_name=""):
+        self.tag = tag
+        self.aliases = frozenset(aliases)
+        self.primary = primary
+        self.jira_name = jira_name
 
-    # Cleanup any remaining symbols:
-    pattern = re.compile(r"^\W+(.*)", re.IGNORECASE)
-    if pattern.search(text) is not None:
-        text = pattern.search(text).groups()[0]
+    def matches(self, token):
+        return token == self.tag or token in self.aliases
 
-    # Assemble full text (JIRA ref(s), module(s), remaining text)
-    clean_text = "".join(jira_refs).strip() + "".join(components).strip() + " 
" + text.strip()
+    @classmethod
+    def find(cls, token):
+        """Return the Component matching ``token`` (case-insensitive), or 
None."""
+        if token is None:
+            return None
+        token = token.strip().upper()
+        for c in COMPONENTS:
+            if c.matches(token):
+                return c
+        return None
 
-    # Replace multiple spaces with a single space, e.g. if no jira refs and/or 
components were
-    # included
-    clean_text = re.sub(r"\s+", " ", clean_text.strip())
 
-    return clean_text
+# Full SPARK JIRA component list (sorted alphabetically by tag), followed
+# by status markers. Keep in sync with the components in JIRA — fetch the
+# current list with:
+#   curl -s https://issues.apache.org/jira/rest/api/2/project/SPARK/components
+# A `primary=True` marker indicates the tag alone satisfies the merge-time
+# component requirement; non-primary JIRA components must be paired with a
+# primary one (e.g. [SQL][TEST], [CORE][SHUFFLE]). Status
+# markers leave `jira_name` empty.
+COMPONENTS = (
+    Component("BLOCK_MANAGER", jira_name="Block Manager"),
+    Component("BUILD", primary=True, jira_name="Build"),
+    Component("CONNECT", primary=True, jira_name="Connect"),
+    Component("CORE", ("SPARK_CORE",), primary=True, jira_name="Spark Core"),
+    Component("DEPLOY", jira_name="Deploy"),
+    Component("DOC", ("DOCS", "DOCUMENTATION"), primary=True, 
jira_name="Documentation"),
+    Component("DOCKER", primary=True, jira_name="Spark Docker"),
+    Component("EC2", jira_name="EC2"),
+    Component("EXAMPLE", ("EXAMPLES",), jira_name="Examples"),
+    Component("GRAPHX", primary=True, jira_name="GraphX"),
+    Component("INFRA", ("PROJECT_INFRA",), primary=True, jira_name="Project 
Infra"),
+    Component("IO", jira_name="Input/Output"),
+    Component("JAVA", ("JAVA_API", "JAVAAPI"), jira_name="Java API"),
+    Component("K8S", ("KUBERNETES",), primary=True, jira_name="Kubernetes"),
+    Component("MESOS", jira_name="Mesos"),
+    Component("ML", primary=True, jira_name="ML"),
+    Component("MLLIB", primary=True, jira_name="MLlib"),
+    Component("OPTIMIZER", jira_name="Optimizer"),
+    Component("PROTOBUF", jira_name="Protobuf"),
+    Component("PS", primary=True, jira_name="Pandas API on Spark"),
+    Component("PYTHON", ("PYSPARK",), primary=True, jira_name="PySpark"),
+    Component("R", ("SPARKR",), primary=True, jira_name="R"),
+    Component("REPL", ("SHELL", "SPARK_SHELL"), jira_name="Spark Shell"),
+    Component("SCHEDULER", jira_name="Scheduler"),
+    Component("SDP", ("PIPELINES",), primary=True, jira_name="Declarative 
Pipelines"),
+    Component("SECURITY", primary=True, jira_name="Security"),
+    Component("SHUFFLE", jira_name="Shuffle"),
+    Component("SQL", primary=True, jira_name="SQL"),
+    Component("SS", primary=True, jira_name="Structured Streaming"),
+    Component("STREAMING", ("DSTREAM", "DSTREAMS"), primary=True, 
jira_name="DStreams"),
+    Component("SUBMIT", jira_name="Spark Submit"),
+    Component("TEST", ("TESTS", "TEST-ONLY", "TESTS-ONLY"), jira_name="Tests"),
+    Component("UI", ("WEBUI", "WEB_UI"), primary=True, jira_name="Web UI"),
+    Component("WINDOWS", primary=True, jira_name="Windows"),
+    Component("YARN", primary=True, jira_name="YARN"),
+    # Status markers — recognized in PR titles, but not JIRA components.
+    Component("FOLLOWUP", ("FOLLOW-UP",)),
+    Component("MINOR"),
+    Component("TRIVIAL"),
+)
+
+
+_BRACKET_TAG_RE = re.compile(r"\[\s*([A-Za-z0-9._-]+)\s*\]")
+_SPARK_ID_RE = re.compile(r"^SPARK-\d+$", re.IGNORECASE)
+_VERSION_TAG_RE = re.compile(r"^\d+\.(\d+|X)$")
+_LEADING_TAGS = frozenset({"MINOR", "TRIVIAL"})
+
+
+class Title:
+    """Structured PR title: SPARK refs, component tags, and body.
+
+    ``leading``    — SPARK-NNNNN IDs and [MINOR]/[TRIVIAL] markers, in order.
+    ``components`` — all other bracket tags, in order.
+    ``text``       — body text following the bracket sequence.
+
+    >>> t = Title.parse("[SPARK-1234][SQL] Fix something")
+    >>> t.leading, t.components, t.text
+    (['SPARK-1234'], ['SQL'], 'Fix something')
+    >>> str(t)
+    '[SPARK-1234][SQL] Fix something'
+    >>> t = Title.parse("[SPARK-1234][SQL][FOLLOWUP] Fix something")
+    >>> t.leading, t.components, t.text
+    (['SPARK-1234'], ['SQL', 'FOLLOWUP'], 'Fix something')
+    >>> str(t)
+    '[SPARK-1234][SQL][FOLLOWUP] Fix something'
+    >>> t = Title.parse("[SPARK-1234]")
+    >>> t.leading, t.components, t.text
+    (['SPARK-1234'], [], '')
+    >>> str(t)
+    '[SPARK-1234]'
+    """
+
+    def __init__(
+        self,
+        leading: List[str],
+        components: List[str],
+        text: str,
+    ) -> None:
+        self.leading = leading
+        self.components = components
+        self.text = text
+
+    @classmethod
+    def parse(cls, raw: str) -> "Title":
+        """Parse a PR title string into a :class:`Title`.
+
+        A title must open with a leading tag ([SPARK-NNNNN], [MINOR], or
+        [TRIVIAL]); otherwise :exc:`ValueError` is raised.  Subsequent bracket
+        tokens (spaces trimmed, separated by optional whitespace) go to
+        ``components``.  The remainder is ``text``.
+
+        >>> t = Title.parse("[SPARK-1234][SQL][TESTS] Fix something")
+        >>> t.leading, t.components, t.text
+        (['SPARK-1234'], ['SQL', 'TESTS'], 'Fix something')
+        >>> t = Title.parse("  [ SPARK-1234 ]  [ SQL ] [  TESTS  ]   Fix 
something")
+        >>> t.leading, t.components, t.text
+        (['SPARK-1234'], ['SQL', 'TESTS'], 'Fix something')
+        >>> t = Title.parse("[SPARK-1234 ][ sql ][ followup ] Fix")
+        >>> t.leading, t.components, t.text
+        (['SPARK-1234'], ['SQL', 'FOLLOWUP'], 'Fix')
+        >>> str(t)
+        '[SPARK-1234][SQL][FOLLOWUP] Fix'
+        >>> Title.parse("[MINOR] Fix typo").leading
+        ['MINOR']
+        >>> t = Title.parse("[spark-1234][sql][followup] Fix")
+        >>> t.leading, t.components
+        (['SPARK-1234'], ['SQL', 'FOLLOWUP'])
+        >>> Title.parse("[SPARK-1234][SPARK-5678][SQL] Fix").leading
+        ['SPARK-1234', 'SPARK-5678']
+        >>> Title.parse("[SPARK-1234][4.X][SQL] Fix").components
+        ['4.X', 'SQL']
+        >>> Title.parse("[SPARK-1234][SQL][4.2] Fix").components
+        ['SQL', '4.2']
+        >>> Title.parse("[SQL] Fix")
+        Traceback (most recent call last):
+            ...
+        ValueError: title must start with [SPARK-NNNNN], [MINOR], or 
[TRIVIAL]: '[SQL] Fix'
+        >>> Title.parse("No brackets")
+        Traceback (most recent call last):
+            ...
+        ValueError: title must start with [SPARK-NNNNN], [MINOR], or 
[TRIVIAL]: 'No brackets'
+        >>> Title.parse("[SPARK-1234][SQL][SPARK-123] Fix")
+        Traceback (most recent call last):
+            ...
+        ValueError: [SPARK-NNNNN] tags must all appear before other tags: 
'[SPARK-1234][SQL][SPARK-123] Fix'
+        >>> Title.parse("[SPARK-1234][MINOR][SQL] Fix")
+        Traceback (most recent call last):
+            ...
+        ValueError: [SPARK-NNNNN], [MINOR], and [TRIVIAL] cannot coexist
+        >>> Title.parse("[MINOR][TRIVIAL][SQL] Fix")
+        Traceback (most recent call last):
+            ...
+        ValueError: [SPARK-NNNNN], [MINOR], and [TRIVIAL] cannot coexist
+        """
+        leading: List[str] = []
+        components: List[str] = []
+
+        raw = raw.strip()
+        m0 = _BRACKET_TAG_RE.match(raw)
+        first = m0.group(1).upper() if m0 else ""
+        if not (_SPARK_ID_RE.match(first) or first in _LEADING_TAGS):
+            raise ValueError("title must start with [SPARK-NNNNN], [MINOR], or 
[TRIVIAL]: %r" % raw)
+
+        past_leading = False
+        pos = 0
+        while pos < len(raw):
+            m = _BRACKET_TAG_RE.match(raw, pos)
+            if not m:
+                break
+            tag = m.group(1).upper()
+            if _SPARK_ID_RE.match(tag):
+                if past_leading:
+                    raise ValueError(
+                        "[SPARK-NNNNN] tags must all appear before other tags: 
%r" % raw
+                    )
+                leading.append(tag)
+            elif tag in _LEADING_TAGS:
+                leading.append(tag)
+            else:
+                components.append(tag)
+                past_leading = True
+            pos = m.end()
+            while pos < len(raw) and raw[pos] == " ":
+                pos += 1
+
+        text = raw[pos:].lstrip()
+        markers = [t for t in leading if t in _LEADING_TAGS]
+        if len(markers) > 1 or (markers and len(leading) > len(markers)):
+            raise ValueError("[SPARK-NNNNN], [MINOR], and [TRIVIAL] cannot 
coexist")
+        return cls(leading, components, text)
+
+    def __str__(self) -> str:
+        parts = "".join("[%s]" % t for t in self.leading)
+        parts += "".join("[%s]" % c for c in self.components)
+        if not self.text:
+            return parts
+        return parts + (" " if parts else "") + self.text
+
+
+def prompt_for_components():
+    """
+    Prompt the committer for component(s) when the PR title lacks a primary
+    component. Each entered token is normalized via Component.find
+    (e.g. "DOCS" -> "DOC", "PYSPARK" -> "PYTHON"). Unrecognized tokens are
+    passed through as-is. Re-prompts until at least one entered token resolves
+    to a primary Component (one with primary=True). Returns an uppercase list
+    of tags in insertion order.
+    """
+    print("PR title is missing a primary [COMPONENT] tag.")
+    print("Primary components (one of these is required):")
+    primary = [c for c in COMPONENTS if c.primary]
+    width = max(len(c.tag) for c in primary)
+    for c in primary:
+        print("  [%s]%s - %s" % (c.tag, " " * (width - len(c.tag)), 
c.jira_name))
+    while True:
+        raw = bold_input(
+            "Enter comma-separated component(s) to insert into the title (e.g. 
CORE,SQL): "
+        )
+        components = []
+        has_primary = False
+        for token in raw.split(","):
+            t = token.strip().upper()
+            if t:
+                c = Component.find(t)
+                if c is not None and c.primary:
+                    has_primary = True
+                components.append(c.tag if c else t)
+        if not components:
+            print_error("Component(s) cannot be empty. Please enter at least 
one.")
+            continue
+        if not has_primary:
+            print_error(
+                "At least one component must be a primary tag (see list 
above). "
+                "Got: %s" % ", ".join(components)
+            )
+            continue
+        return components
 
 
 def get_current_ref():
@@ -917,28 +1114,62 @@ def main():
     pr_events = get_json("%s/issues/%s/events" % (GITHUB_API_BASE, pr_num))
 
     url = pr["url"]
+    title = pr["title"]
 
-    # Warn if the PR is WIP
-    if "[WIP]" in pr["title"]:
-        msg = "The PR title has `[WIP]`:\n%s\nContinue?" % pr["title"]
-        continue_maybe(msg)
+    # Fail hard on WIP or DO-NOT-MERGE to prevent accidental merges.
+    if "[WIP]" in title or "[DO-NOT-MERGE]" in title:
+        fail("Cannot merge a PR with [WIP] or [DO-NOT-MERGE] in the 
title:\n%s" % title)
 
-    # Decide whether to use the modified title or not
-    modified_title = standardize_jira_ref(pr["title"]).rstrip(".")
-    if modified_title != pr["title"]:
-        print("I've re-written the title as follows to match the standard 
format:")
-        print("Original: %s" % pr["title"])
-        print("Modified: %s" % modified_title)
-        result = bold_input("Would you like to use the modified title? (y/N): 
")
-        if result.lower() == "y":
-            title = modified_title
-            print("Using modified title:")
-        else:
-            title = pr["title"]
-            print("Using original title:")
-        print(title)
-    else:
-        title = pr["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('"')
+
+    # 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(c.tag if c is not None else 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 = str(parsed)
+        if title != pr["title"]:
+            print("Normalized title: %s" % title)
 
     body = pr["body"]
     if body is None:


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

Reply via email to