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]