Repository: twisted/towncrier

Issue:
Issues numbers added after rst preformatted blocks breaks rendering
From #608: 
> An interesting aside: without some text after the pre-formatted block, it breaks the rendering of the issue number towncrier adds when building because it's considered part of the block.

For example, a file `newsfragments/608.feature.rst`:
```rst
Something was fixed. Now this is a thing::

    def code_block():
        ...
```

will produce:

```rst
- Something was fixed. Now this is a thing::

      def code_block():
          ... (`#608 <https://github.com/twisted/towncrier/issues/608>`_)
```
Which renders as:
![image](https://github.com/twisted/towncrier/assets/109951/8a111f33-7e21-4b56-9029-c679f79c1700)

The solution is probably to Prepend `\n\n` to the issue number in this case (a single newline will result in `(WARNING/2) Literal block ends without a blank line; unexpected unindent.`)

Maybe it's easier always putting the issue number in a separate paragraph if any new lines exist in the fragment content?

Discussion:

Patch:
diff --git a/src/towncrier/_builder.py b/src/towncrier/_builder.py
index dca09da..33ebe16 100644
--- a/src/towncrier/_builder.py
+++ b/src/towncrier/_builder.py
@@ -328,6 +328,29 @@ def render_issue(issue_format: str | None, issue: str) -> str:
         return issue_format.format(issue=issue)
 
 
+def append_newlines_if_trailing_code_block(text: str) -> str:
+    """
+    Appends two newlines to a text string if it ends with a code block.
+
+    Used by `render_fragments` to avoid appending link to issue number into the code block.
+    """
+    # Search for the existence of a code block at the end. We do this by searching for:
+    # 1. start of code block: two ":", followed by two newlines
+    # 2. any number of indented, or empty, lines (or the code block would end)
+    # 3. one line of indented text w/o a trailing newline (because the string is stripped)
+    # 4. end of the string.
+    indented_text = r"  [ \t]+[^\n]*"
+    empty_or_indented_text_lines = f"(({indented_text})?\n)*"
+    regex = r"::\n\n" + empty_or_indented_text_lines + indented_text + "$"
+    if re.search(regex, text):
+        # We insert one space, the default template inserts another, which results
+        # in the correct indentation given default bullet indentation.
+        # Non-default templates with different indentation will likely encounter issues
+        # if they have trailing code blocks.
+        return text + "\n\n "
+    return text
+
+
 def render_fragments(
     template: str,
     issue_format: str | None,
@@ -381,6 +404,7 @@ def render_fragments(
             # for the template, after formatting each issue number
             categories = {}
             for text, issues in entries:
+                text = append_newlines_if_trailing_code_block(text)
                 rendered = [render_issue(issue_format, i) for i in issues]
                 categories[text] = rendered
 
diff --git a/src/towncrier/newsfragments/614.bugfix.rst b/src/towncrier/newsfragments/614.bugfix.rst
new file mode 100644
index 0000000..f8b12a6
--- /dev/null
+++ b/src/towncrier/newsfragments/614.bugfix.rst
@@ -0,0 +1,1 @@
+Multi-line newsfragments that ends with a code block will now have a newline inserted before appending the link to the issue, to avoid breaking formatting.


Test patch:
diff --git a/src/towncrier/test/test_format.py b/src/towncrier/test/test_format.py
index a1efefb..7be2877 100644
--- a/src/towncrier/test/test_format.py
+++ b/src/towncrier/test/test_format.py
@@ -462,3 +462,74 @@ Features
             versiondata={"name": "MyProject", "version": "1.0", "date": "never"},
         )
         self.assertEqual(output, expected_output)
+
+    def test_trailing_block(self) -> None:
+        """
+        Make sure a newline gets inserted before appending the issue number, if the
+        newsfragment ends with an indented block.
+        """
+
+        fragments = {
+            "": {
+                (
+                    "1",
+                    "feature",
+                    0,
+                ): (
+                    "this fragment has a trailing code block::\n\n"
+                    "    def foo(): ...\n\n"
+                    "   \n"
+                    "    def bar(): ..."
+                ),
+                (
+                    "2",
+                    "feature",
+                    0,
+                ): (
+                    "this block is not trailing::\n\n"
+                    "    def foo(): ...\n"
+                    "    def bar(): ...\n\n"
+                    "so we can append the issue number directly after this"
+                ),
+            }
+        }
+        # the line with 3 spaces (and nothing else) is stripped
+        expected_output = """MyProject 1.0 (never)
+=====================
+
+Features
+--------
+
+- this fragment has a trailing code block::
+
+      def foo(): ...
+
+
+      def bar(): ...
+
+  (#1)
+- this block is not trailing::
+
+      def foo(): ...
+      def bar(): ...
+
+  so we can append the issue number directly after this (#2)
+
+
+"""
+
+        definitions = {
+            "feature": {"name": "Features", "showcontent": True},
+        }
+        template = read_pkg_resource("templates/default.rst")
+        fragments_split = split_fragments(fragments, definitions)
+        output = render_fragments(
+            template,
+            None,
+            fragments_split,
+            definitions,
+            ["-", "~"],
+            wrap=True,
+            versiondata={"name": "MyProject", "version": "1.0", "date": "never"},
+        )
+        self.assertEqual(output, expected_output)