Repository: twisted/towncrier
Pull request #482 (2023-02-21 22:15:19)

Interactive create
# Description
If no filename is given when doing `towncrier create`, interactively ask for the issue number and fragment type (and then launch an interactive editor for the newsfragment content).

```console
$ towncrier create
Issue number (`+` for none): 482
Fragment type (feature, bugfix, doc, removal, misc): feature
($EDITOR opened here)
Created news fragment at /home/chris/dev/towncrier/src/towncrier/newsfragments/482.feature.rst
```

## Reworded interactive edit comment
The comment given to the editor has been reworded to use the similar format as a git commit interactive edit, with the empty space above rather than below:
```
█
# Please write your news content. Lines starting with '#' will be ignored, and
# an empty message aborts.
```

## Default news fragment filename extension
If the file extension of the `filename` configuration setting ends in `.rst` or `.md`, `towncrier create` will append this extension to newsfragments it creates (if the filename didn't provide an explicit extension).
```console
$ towncrier create 1.feature
Created news fragment at /home/chris/dev/towncrier/src/towncrier/newsfragments/1.feature.rst
$ towncrier create 2.feature.txt
Created news fragment at /home/chris/dev/towncrier/src/towncrier/newsfragments/2.feature.txt
```
This is configurable with a new `create_add_extension` setting (`true` by default).

## Add an empty line to the end of news fragments
An empty line is now added to the end of news fragment content, whether entered by the command line option or interactively.
This is configurable with a new `create_eof_newline` setting (`true` by default).

# Checklist
<!-- add a "X" inside the brackets to confirm -->
* [x] Make sure changes are covered by existing or new tests.
* [x] For at least one Python version, make sure local test run is green.
* [x] Create a file in `src/towncrier/newsfragments/`. Describe your
  change and include important information. Your change will be included in the public release notes.
* [x] Make sure all GitHub Actions checks are green (they are automatically checking all of the above).
* [x] Ensure `docs/tutorial.rst` is still up-to-date.
* [x] If you add new **CLI arguments** (or change the meaning of existing ones), make sure `docs/cli.rst` reflects those changes.
* [x] If you add new **configuration options** (or change the meaning of existing ones), make sure `docs/configuration.rst` reflects those changes.


codecov[bot]: # [Codecov](https://codecov.io/gh/twisted/towncrier/pull/482?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) Report
Base: **100.00**% // Head: **99.84**% // Decreases project coverage by **`-0.16%`** :warning:
> Coverage data is based on head [(`c594476`)](https://codecov.io/gh/twisted/towncrier/pull/482?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) compared to base [(`826beac`)](https://codecov.io/gh/twisted/towncrier/commit/826beacb7e96a3360ed9e5153e4be06dd74b028d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).
> Patch coverage: 97.22% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##             trunk     #482      +/-   ##
===========================================
- Coverage   100.00%   99.84%   -0.16%     
===========================================
  Files           13       13              
  Lines          627      651      +24     
  Branches       145      155      +10     
===========================================
+ Hits           627      650      +23     
- Partials         0        1       +1     
```


| [Impacted Files](https://codecov.io/gh/twisted/towncrier/pull/482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) | Coverage Δ | |
|---|---|---|
| [src/towncrier/create.py](https://codecov.io/gh/twisted/towncrier/pull/482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9jcmVhdGUucHk=) | `98.64% <97.05%> (-1.36%)` | :arrow_down: |
| [src/towncrier/\_settings/load.py](https://codecov.io/gh/twisted/towncrier/pull/482?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fc2V0dGluZ3MvbG9hZC5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/twisted/towncrier/pull/482?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).

adiroiban: Thanks Chris for this nice PR.
I will try to find some time to review this...but I am very busy this need and next week.
I hope someone else will have time to review it.
If someone left unreviewed don't hesitate to ping me and I will to review.

At a first review, my initial comment was to move the configuration changes into a separate PR and focus this PR only on the interactive part.
In this way, it might be easier to merge one part of this PR, without waiting for other part that might need more work/feedback.

But I am also ok to review this together. It's just that it will take more time to review.

Thanks again for your help.
SmileyChris: > But I am also ok to review this together. It's just that it will take more time to review.

Yes, I threw in a bit more than I should have... but it was all intertwined and it was too messy to provide them as individual PRs. If any area becomes contentious, I'm happy to remove it from this PR.

Preemptively defending the secondary changes:
* Interactive edit rewording: this should be pretty straight forward, it just brings things into line with a git interactive commit, and having the empty line first is a nice advantage to allow typing straight away in most editors.
* Default news fragment: this is a completely optional addition, but one that fit nicely with recent discussions on file extensions we were having. Having a `.rst` (or `.md`) extension makes the experience nicer for users who have an editor that styles content based on file type.
* Adding empty lines to fragments: functionally it doesn't change anything with output, but it is good practice (and without, it's technically not even technically a text file according to IEEE Std 1003.1-2001: A [text file](http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap03.html#tag_03_205) consists of a series of [lines](http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap03.html#tag_03_205), each of which requires a terminating newline character). A common pre-commit hook adds a newline the end of files.
adiroiban: Thanks. I will try to take a look at this next week.
adiroiban: Sorry Chris for this late review.
It was still marked as unread... but went at the bottom of my github notifications list.

If you are still interested in this. I think that as long as the tests are green, this can be merged.

I don't know whey I will have time to test this.
adiroiban: @SmileyChris I think that you are already in the top 5 contributors of this repo for the last few years, so I consider you one of the de-facto maintainers 

Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index d2fa0d4..478a88b 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -72,6 +72,8 @@ Create a news fragment in the directory that ``towncrier`` is configured to look
 
    $ towncrier create 123.bugfix.rst
 
+If you don't provide a file name, ``towncrier`` will prompt you for one.
+
 ``towncrier create`` will enforce that the passed type (e.g. ``bugfix``) is valid.
 
 If the fragments directory does not exist, it will be created.
@@ -91,9 +93,10 @@ If that is the entire fragment name, a random hash will be added for you::
    A string to use for content.
    Default: an instructive placeholder.
 
-.. option:: --edit
+.. option:: --edit / --no-edit
 
-   Create file and start `$EDITOR` to edit it right away.
+   Whether to start ``$EDITOR`` to edit the news fragment right away.
+   Default: ``$EDITOR`` will be started unless you also provided content.
 
 
 ``towncrier check``
diff --git a/docs/configuration.rst b/docs/configuration.rst
index d424e1c..f0eaeda 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -118,6 +118,16 @@ Top level keys
 
     ``"+"`` by default.
 
+``create_eof_newline``
+    Ensure the content of a news fragment file created with ``towncrier create`` ends with an empty line.
+
+    ``true`` by default.
+
+``create_add_extension``
+    Add the ``filename`` option extension to news fragment files created with ``towncrier create`` if an extension is not explicitly provided.
+
+    ``true`` by default.
+
 Extra top level keys for Python projects
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/src/towncrier/_settings/load.py b/src/towncrier/_settings/load.py
index 0b84566..66a6c54 100644
--- a/src/towncrier/_settings/load.py
+++ b/src/towncrier/_settings/load.py
@@ -52,6 +52,8 @@ class Config:
     wrap: bool = False
     all_bullets: bool = True
     orphan_prefix: str = "+"
+    create_eof_newline: bool = True
+    create_add_extension: bool = True
 
 
 class ConfigError(ClickException):
diff --git a/src/towncrier/create.py b/src/towncrier/create.py
index de26412..362b2ba 100644
--- a/src/towncrier/create.py
+++ b/src/towncrier/create.py
@@ -14,6 +14,9 @@ import click
 from ._settings import config_option_help, load_config_from_options
 
 
+DEFAULT_CONTENT = "Add your info here"
+
+
 @click.command(name="create")
 @click.pass_context
 @click.option(
@@ -32,30 +35,32 @@ from ._settings import config_option_help, load_config_from_options
 )
 @click.option(
     "--edit/--no-edit",
-    default=False,
+    default=None,
     help="Open an editor for writing the newsfragment content.",
-)  # TODO: default should be true
+)
 @click.option(
     "-c",
     "--content",
     type=str,
-    default="Add your info here",
+    default=DEFAULT_CONTENT,
     help="Sets the content of the new fragment.",
 )
-@click.argument("filename")
+@click.argument("filename", default="")
 def _main(
     ctx: click.Context,
     directory: str | None,
     config: str | None,
     filename: str,
-    edit: bool,
+    edit: bool | None,
     content: str,
 ) -> None:
     """
     Create a new news fragment.
 
-    Create a new news fragment called FILENAME or pass the full path for a file.
-    Towncrier has a few standard types of news fragments, signified by the file extension.
+    If FILENAME is not provided, you'll be prompted to create it.
+
+    Towncrier has a few standard types of news fragments, signified by the file
+    extension.
 
     \b
     These are:
@@ -76,7 +81,7 @@ def __main(
     directory: str | None,
     config_path: str | None,
     filename: str,
-    edit: bool,
+    edit: bool | None,
     content: str,
 ) -> None:
     """
@@ -84,6 +89,26 @@ def __main(
     """
     base_directory, config = load_config_from_options(directory, config_path)
 
+    filename_ext = ""
+    if config.create_add_extension:
+        ext = os.path.splitext(config.filename)[1]
+        if ext.lower() in (".rst", ".md"):
+            filename_ext = ext
+
+    if not filename:
+        prompt = "Issue number"
+        # Add info about adding orphan if config is set.
+        if config.orphan_prefix:
+            prompt += f" (`{config.orphan_prefix}` if none)"
+        issue = click.prompt(prompt)
+        fragment_type = click.prompt(
+            "Fragment type",
+            type=click.Choice(list(config.types)),
+        )
+        filename = f"{issue}.{fragment_type}"
+        if edit is None and content == DEFAULT_CONTENT:
+            edit = True
+
     file_dir, file_basename = os.path.split(filename)
     if config.orphan_prefix and file_basename.startswith(f"{config.orphan_prefix}."):
         # Append a random hex string to the orphan news fragment base name.
@@ -94,15 +119,18 @@ def __main(
                 f"{file_basename[len(config.orphan_prefix):]}"
             ),
         )
-    if len(filename.split(".")) < 2 or (
-        filename.split(".")[-1] not in config.types
-        and filename.split(".")[-2] not in config.types
+    filename_parts = filename.split(".")
+    if len(filename_parts) < 2 or (
+        filename_parts[-1] not in config.types
+        and filename_parts[-2] not in config.types
     ):
         raise click.BadParameter(
             "Expected filename '{}' to be of format '{{name}}.{{type}}', "
             "where '{{name}}' is an arbitrary slug and '{{type}}' is "
             "one of: {}".format(filename, ", ".join(config.types))
         )
+    if filename_parts[-1] in config.types and filename_ext:
+        filename += filename_ext
 
     if config.directory:
         fragments_directory = os.path.abspath(
@@ -135,31 +163,34 @@ def __main(
         )
 
     if edit:
-        edited_content = _get_news_content_from_user(content)
-        if edited_content is None:
-            click.echo("Abort creating news fragment.")
+        if content == DEFAULT_CONTENT:
+            content = ""
+        content = _get_news_content_from_user(content)
+        if not content:
+            click.echo("Aborted creating news fragment due to empty message.")
             ctx.exit(1)
-        content = edited_content
 
     with open(segment_file, "w") as f:
         f.write(content)
+        if config.create_eof_newline and content and not content.endswith("\n"):
+            f.write("\n")
 
     click.echo(f"Created news fragment at {segment_file}")
 
 
-def _get_news_content_from_user(message: str) -> str | None:
-    initial_content = (
-        "# Please write your news content. When finished, save the file.\n"
-        "# In order to abort, exit without saving.\n"
-        '# Lines starting with "#" are ignored.\n'
-    )
-    initial_content += f"\n{message}\n"
+def _get_news_content_from_user(message: str) -> str:
+    initial_content = """
+# Please write your news content. Lines starting with '#' will be ignored, and
+# an empty message aborts.
+"""
+    if message:
+        initial_content = f"{message}\n{initial_content}"
     content = click.edit(initial_content)
     if content is None:
-        return None
+        return message
     all_lines = content.split("\n")
     lines = [line.rstrip() for line in all_lines if not line.lstrip().startswith("#")]
-    return "\n".join(lines)
+    return "\n".join(lines).strip()
 
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/src/towncrier/newsfragments/482.feature.rst b/src/towncrier/newsfragments/482.feature.rst
new file mode 100644
index 0000000..81b6660
--- /dev/null
+++ b/src/towncrier/newsfragments/482.feature.rst
@@ -0,0 +1,5 @@
+If no filename is given when doing ``towncrier`` create, interactively ask for the issue number and fragment type (and then launch an interactive editor for the fragment content).
+
+Now by default, when creating a fragment it will be appended with the ``filename`` option's extension (unless an extension is explicitly provided). For example, ``towncrier create 123.feature`` will create ``news/123.feature.rst``. This can be changed in configuration file by setting `add_extension = false`.
+
+A new line is now added by default to the end of the fragment contents. This can be reverted in the configuration file by setting `add_newline = false`.


Test patch:
diff --git a/src/towncrier/test/test_create.py b/src/towncrier/test/test_create.py
index 7f0e24b..e946f20 100644
--- a/src/towncrier/test/test_create.py
+++ b/src/towncrier/test/test_create.py
@@ -11,7 +11,7 @@ from unittest import mock
 from click.testing import CliRunner
 from twisted.trial.unittest import TestCase
 
-from ..create import _main
+from ..create import DEFAULT_CONTENT, _main
 from .helpers import setup_simple_project, with_isolated_runner
 
 
@@ -19,7 +19,12 @@ class TestCli(TestCase):
     maxDiff = None
 
     def _test_success(
-        self, content=None, config=None, mkdir=True, additional_args=None
+        self,
+        content=None,
+        config=None,
+        mkdir=True,
+        additional_args=None,
+        eof_newline=True,
     ):
         runner = CliRunner()
 
@@ -28,15 +33,17 @@ class TestCli(TestCase):
 
             args = ["123.feature.rst"]
             if content is None:
-                content = ["Add your info here"]
+                content = [DEFAULT_CONTENT]
             if additional_args is not None:
                 args.extend(additional_args)
             result = runner.invoke(_main, args)
 
             self.assertEqual(["123.feature.rst"], os.listdir("foo/newsfragments"))
 
+            if eof_newline:
+                content.append("")
             with open("foo/newsfragments/123.feature.rst") as fh:
-                self.assertEqual(content, fh.readlines())
+                self.assertEqual("\n".join(content), fh.read())
 
         self.assertEqual(0, result.exit_code)
 
@@ -50,24 +57,21 @@ class TestCli(TestCase):
 
     def test_edit_without_comments(self):
         """Create file with dynamic content."""
-        content = ["This is line 1\n", "This is line 2"]
+        content = ["This is line 1", "This is line 2"]
         with mock.patch("click.edit") as mock_edit:
-            mock_edit.return_value = "".join(content)
+            mock_edit.return_value = "\n".join(content)
             self._test_success(content=content, additional_args=["--edit"])
             mock_edit.assert_called_once_with(
-                "# Please write your news content. When finished, save the file.\n"
-                "# In order to abort, exit without saving.\n"
-                '# Lines starting with "#" are ignored.\n'
-                "\n"
-                "Add your info here\n"
+                "\n# Please write your news content. Lines starting "
+                "with '#' will be ignored, and\n# an empty message aborts.\n"
             )
 
     def test_edit_with_comment(self):
         """Create file editly with ignored line."""
-        content = ["This is line 1\n", "This is line 2"]
-        comment = "# I am ignored\n"
+        content = ["This is line 1", "This is line 2"]
+        comment = "# I am ignored"
         with mock.patch("click.edit") as mock_edit:
-            mock_edit.return_value = "".join(content[:1] + [comment] + content[1:])
+            mock_edit.return_value = "\n".join(content[:1] + [comment] + content[1:])
             self._test_success(content=content, additional_args=["--edit"])
 
     def test_edit_abort(self):
@@ -92,6 +96,27 @@ class TestCli(TestCase):
         content_line = "This is a content"
         self._test_success(content=[content_line], additional_args=["-c", content_line])
 
+    def test_content_without_eof_newline(self):
+        """
+        When creating a new fragment the content can be passed as a command line
+        argument. The text editor is not invoked, and no eof newline is added if the
+        config option is set.
+        """
+        config = dedent(
+            """\
+            [tool.towncrier]
+            package = "foo"
+            create_eof_newline = false
+            """
+        )
+        content_line = "This is a content"
+        self._test_success(
+            content=[content_line],
+            additional_args=["-c", content_line],
+            config=config,
+            eof_newline=False,
+        )
+
     def test_message_and_edit(self):
         """
         When creating a new message, a initial content can be passed via
@@ -99,18 +124,15 @@ class TestCli(TestCase):
         text editor.
         """
         content_line = "This is a content line"
-        edit_content = ["This is line 1\n", "This is line 2"]
+        edit_content = ["This is line 1", "This is line 2"]
         with mock.patch("click.edit") as mock_edit:
-            mock_edit.return_value = "".join(edit_content)
+            mock_edit.return_value = "\n".join(edit_content)
             self._test_success(
                 content=edit_content, additional_args=["-c", content_line, "--edit"]
             )
             mock_edit.assert_called_once_with(
-                "# Please write your news content. When finished, save the file.\n"
-                "# In order to abort, exit without saving.\n"
-                '# Lines starting with "#" are ignored.\n'
-                "\n"
-                "{content_line}\n".format(content_line=content_line)
+                f"{content_line}\n\n# Please write your news content. Lines starting "
+                "with '#' will be ignored, and\n# an empty message aborts.\n"
             )
 
     def test_different_directory(self):
@@ -151,12 +173,81 @@ class TestCli(TestCase):
             "Expected filename '123.foobar.rst' to be of format", result.output
         )
 
+    @with_isolated_runner
+    def test_custom_extension(self, runner: CliRunner):
+        """Ensure we can still create fragments with custom extensions."""
+        setup_simple_project()
+        frag_path = Path("foo", "newsfragments")
+
+        result = runner.invoke(_main, ["123.feature.txt"])
+        self.assertEqual(result.exit_code, 0, result.output)
+
+        fragments = [f.name for f in frag_path.iterdir()]
+        # No '.rst' extension added.
+        self.assertEqual(fragments, ["123.feature.txt"])
+
+    @with_isolated_runner
+    def test_md_filename_extension(self, runner: CliRunner):
+        """Ensure changelog filename extension is used if .md"""
+        setup_simple_project(extra_config='filename = "changes.md"')
+        frag_path = Path("foo", "newsfragments")
+
+        result = runner.invoke(_main, ["123.feature"])
+        self.assertEqual(result.exit_code, 0, result.output)
+
+        fragments = [f.name for f in frag_path.iterdir()]
+        # No '.rst' extension added.
+        self.assertEqual(fragments, ["123.feature.md"])
+
+    @with_isolated_runner
+    def test_no_filename_extension(self, runner: CliRunner):
+        """
+        When the NEWS filename has no extension, new fragments are will not have an
+        extension added.
+        """
+        # The name of the file where towncrier will generate
+        # the final release notes is named `RELEASE_NOTES`
+        # for this test (with no file extension).
+        setup_simple_project(extra_config='filename = "RELEASE_NOTES"')
+        frag_path = Path("foo", "newsfragments")
+
+        result = runner.invoke(_main, ["123.feature"])
+        self.assertEqual(result.exit_code, 0, result.output)
+
+        fragments = [f.name for f in frag_path.iterdir()]
+        # No '.rst' extension added.
+        self.assertEqual(fragments, ["123.feature"])
+
     @with_isolated_runner
     def test_file_exists(self, runner: CliRunner):
         """Ensure we don't overwrite existing files."""
         setup_simple_project()
         frag_path = Path("foo", "newsfragments")
 
+        for _ in range(3):
+            result = runner.invoke(_main, ["123.feature"])
+            self.assertEqual(result.exit_code, 0, result.output)
+
+        fragments = [f.name for f in frag_path.iterdir()]
+        self.assertEqual(
+            sorted(fragments),
+            [
+                "123.feature.1.rst",
+                "123.feature.2.rst",
+                "123.feature.rst",
+            ],
+        )
+
+    @with_isolated_runner
+    def test_file_exists_no_ext(self, runner: CliRunner):
+        """
+        Ensure we don't overwrite existing files with when not adding filename
+        extensions.
+        """
+
+        setup_simple_project(extra_config="create_add_extension = false")
+        frag_path = Path("foo", "newsfragments")
+
         for _ in range(3):
             result = runner.invoke(_main, ["123.feature"])
             self.assertEqual(result.exit_code, 0, result.output)
@@ -194,6 +285,107 @@ class TestCli(TestCase):
             ],
         )
 
+    @with_isolated_runner
+    def test_without_filename(self, runner: CliRunner):
+        """
+        When no filename is provided, the user is prompted for one.
+        """
+        setup_simple_project()
+
+        with mock.patch("click.edit") as mock_edit:
+            mock_edit.return_value = "Edited content"
+            result = runner.invoke(_main, input="123\nfeature\n")
+            self.assertFalse(result.exception, result.output)
+            mock_edit.assert_called_once()
+        expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst")
+        self.assertEqual(
+            result.output,
+            f"""Issue number (`+` if none): 123
+Fragment type (feature, bugfix, doc, removal, misc): feature
+Created news fragment at {expected}
+""",
+        )
+        with open(expected) as f:
+            self.assertEqual(f.read(), "Edited content\n")
+
+    @with_isolated_runner
+    def test_without_filename_orphan(self, runner: CliRunner):
+        """
+        The user can create an orphan fragment from the interactive prompt.
+        """
+        setup_simple_project()
+
+        with mock.patch("click.edit") as mock_edit:
+            mock_edit.return_value = "Orphan content"
+            result = runner.invoke(_main, input="+\nfeature\n")
+            self.assertFalse(result.exception, result.output)
+            mock_edit.assert_called_once()
+        expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+")
+        self.assertTrue(
+            result.output.startswith(
+                f"""Issue number (`+` if none): +
+Fragment type (feature, bugfix, doc, removal, misc): feature
+Created news fragment at {expected}"""
+            ),
+            result.output,
+        )
+        # Check that the file was created with a random name
+        created_line = result.output.strip().rsplit("\n", 1)[-1]
+        # Get file names in the newsfragments directory.
+        files = os.listdir(os.path.join(os.getcwd(), "foo", "newsfragments"))
+        # Check that the file name is in the created line.
+        created_fragment = created_line.split(" ")[-1]
+        self.assertIn(Path(created_fragment).name, files)
+        with open(created_fragment) as f:
+            self.assertEqual(f.read(), "Orphan content\n")
+
+    @with_isolated_runner
+    def test_without_filename_no_orphan_config(self, runner: CliRunner):
+        """
+        If an empty orphan prefix is set, orphan creation is turned off from interactive
+        prompt.
+        """
+        setup_simple_project(extra_config='orphan_prefix = ""')
+
+        with mock.patch("click.edit") as mock_edit:
+            mock_edit.return_value = "Edited content"
+            result = runner.invoke(_main, input="+\nfeature\n")
+            self.assertFalse(result.exception, result.output)
+            mock_edit.assert_called_once()
+        expected = os.path.join(os.getcwd(), "foo", "newsfragments", "+.feature.rst")
+        self.assertEqual(
+            result.output,
+            f"""Issue number: +
+Fragment type (feature, bugfix, doc, removal, misc): feature
+Created news fragment at {expected}
+""",
+        )
+        with open(expected) as f:
+            self.assertEqual(f.read(), "Edited content\n")
+
+    @with_isolated_runner
+    def test_without_filename_with_message(self, runner: CliRunner):
+        """
+        When no filename is provided, the user is prompted for one. If a message is
+        provided, the editor isn't opened and the message is used.
+        """
+        setup_simple_project()
+
+        with mock.patch("click.edit") as mock_edit:
+            result = runner.invoke(_main, ["-c", "Fixed this"], input="123\nfeature\n")
+            self.assertFalse(result.exception, result.output)
+            mock_edit.assert_not_called()
+        expected = os.path.join(os.getcwd(), "foo", "newsfragments", "123.feature.rst")
+        self.assertEqual(
+            result.output,
+            f"""Issue number (`+` if none): 123
+Fragment type (feature, bugfix, doc, removal, misc): feature
+Created news fragment at {expected}
+""",
+        )
+        with open(expected) as f:
+            self.assertEqual(f.read(), "Fixed this\n")
+
     @with_isolated_runner
     def test_create_orphan_fragment(self, runner: CliRunner):
         """
@@ -218,16 +410,18 @@ class TestCli(TestCase):
         self.assertEqual(2, len(fragments))
         change1, change2 = fragments
 
-        self.assertEqual(change1.suffix, ".feature")
+        self.assertEqual(change1.suffix, ".rst")
         self.assertTrue(change1.stem.startswith("+"))
-        # Length should be '+' character and 8 random hex characters.
-        self.assertEqual(len(change1.stem), 9)
+        self.assertTrue(change1.stem.endswith(".feature"))
+        # Length should be '+' character, 8 random hex characters, and ".feature".
+        self.assertEqual(len(change1.stem), 1 + 8 + len(".feature"))
 
-        self.assertEqual(change2.suffix, ".feature")
+        self.assertEqual(change2.suffix, ".rst")
         self.assertTrue(change2.stem.startswith("+"))
+        self.assertTrue(change2.stem.endswith(".feature"))
         self.assertEqual(change2.parent, sub_frag_path)
-        # Length should be '+' character and 8 random hex characters.
-        self.assertEqual(len(change2.stem), 9)
+        # Length should be '+' character, 8 random hex characters, and ".feature".
+        self.assertEqual(len(change2.stem), 1 + 8 + len(".feature"))
 
     @with_isolated_runner
     def test_create_orphan_fragment_custom_prefix(self, runner: CliRunner):
@@ -245,10 +439,12 @@ class TestCli(TestCase):
         self.assertEqual(len(fragments), 1)
         change = fragments[0]
         self.assertTrue(change.stem.startswith("$$$"))
-        # Length should be '$$$' characters and 8 random hex characters.
-        self.assertEqual(len(change.stem), 11)
+        # Length should be '$$$' characters, 8 random hex characters, and ".feature".
+        self.assertEqual(len(change.stem), 3 + 8 + len(".feature"))
         # Check the remainder are all hex characters.
-        self.assertTrue(all(c in string.hexdigits for c in change.stem[3:]))
+        self.assertTrue(
+            all(c in string.hexdigits for c in change.stem[3 : -len(".feature")])
+        )
 
     @with_isolated_runner
     def test_in_different_dir_with_nondefault_newsfragments_directory(self, runner):
@@ -282,4 +478,4 @@ class TestCli(TestCase):
         )
 
         self.assertEqual(0, result.exit_code)
-        self.assertTrue(Path("foo/changelog.d/123.feature").exists())
+        self.assertTrue(Path("foo/changelog.d/123.feature.rst").exists())