Repository: twisted/towncrier

Issue:
Add option `--no`
For batch-processing (e.g. when testing), an option `--no` would be helpful:
```
--no           Do not ask for confirmation to remove news fragments, assume "no".
```


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/twisted/towncrier/pull/453?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.81**% // Decreases project coverage by **`-0.18%`** :warning:
> Coverage data is based on head [(`865b0bd`)](https://codecov.io/gh/twisted/towncrier/pull/453?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) compared to base [(`9554985`)](https://codecov.io/gh/twisted/towncrier/commit/9554985492bb023482f0596b668d11bc7351c6ab?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted).
> Patch coverage: 91.17% of modified lines in pull request are covered.

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


```diff
@@             Coverage Diff             @@
##             trunk     #453      +/-   ##
===========================================
- Coverage   100.00%   99.81%   -0.19%     
===========================================
  Files           22       22              
  Lines         1607     1639      +32     
  Branches       286      293       +7     
===========================================
+ Hits          1607     1636      +29     
- Misses           0        2       +2     
- Partials         0        1       +1     
```


| [Impacted Files](https://codecov.io/gh/twisted/towncrier/pull/453?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted) | Coverage Δ | |
|---|---|---|
| [src/towncrier/build.py](https://codecov.io/gh/twisted/towncrier/pull/453/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9idWlsZC5weQ==) | `96.66% <62.50%> (-3.34%)` | :arrow_down: |
| [src/towncrier/\_git.py](https://codecov.io/gh/twisted/towncrier/pull/453/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci9fZ2l0LnB5) | `100.00% <100.00%> (ø)` | |
| [src/towncrier/test/test\_build.py](https://codecov.io/gh/twisted/towncrier/pull/453/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=twisted#diff-c3JjL3Rvd25jcmllci90ZXN0L3Rlc3RfYnVpbGQucHk=) | `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/453?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: Hi Grzegorz. Many thanks for the nice PR.

I don't really understand why this is needed.

There is the "draft" command which is expected to generate the release notes, without removing the fragments.

It is true that right now, the draft command generates the result in the standard output.

But maybe a better way to fix this is to add a file option to the existing "draft" command.

This is just a suggestion. 
fizyk: @adiroiban I do not have strong opinion on where. It seemed natural, although the --draft could also be the solution.

The underlying issue suggests it would be useful to generate a `current` changelog for development documentation, my issue is that having numerous fragment types I want to have a general changelog with all the changes and additionally specific categories being generated into its own, separate changelog file as well.
adiroiban: Thanks for the explanation. I think '--keep' makes sense.

> The underlying issue suggests it would be useful to generate a current changelog for development documentation, 

I think that this is "draft" use case.
But "draft" has clear documentation that it sends the result to stdout... so... it's complicated.

I will check the PR now and I hope we can merge it.

Thanks again
adiroiban: >  I think actions have different token handling

As far as I know, the actions are just a "step" , so they run in the context of the job.

We are using the default token...and the default token only has read permissions.
[As documented here](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs)

There is this setting [here](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#configuring-the-default-github_token-permissions)  
but I don't know what are the side effects 
hynek: @adiroiban sadly I'm rather clueless at all these things. If you want to just quickly unblock contributions I can offer [my method](https://hynek.me/articles/ditch-codecov-python/) that doesn't comment, but at least works for now. People just have to look into the CI output for missing lines. 
adiroiban: Thanks @hynek . Yes, the commit status might be the only "safe" option.

In theory, we could give write access to forks, since our workflow has explicit permissions... but if in the future a new workflow is created without explicit permissions, this can lead to a security void, as we might accidentally give full access to the repo to forks.

At the same time, I think that new contributors need to have their first PR manually approved, so the workflow code is not executed right away, unattended.

I will look to create a new PR that will always send the commit status and will post a comment only for non-forks.


Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index bf3665f..b32db63 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -50,6 +50,10 @@ Build the combined news file from news fragments.
    Do not ask for confirmations.
    Useful for automated tasks.
 
+.. option:: --keep
+
+   Don't delete news fragments after the build and don't ask for confirmation whether to delete or keep the fragments.
+
 
 ``towncrier create``
 --------------------
diff --git a/src/towncrier/_git.py b/src/towncrier/_git.py
index c436087..a0f9aee 100644
--- a/src/towncrier/_git.py
+++ b/src/towncrier/_git.py
@@ -7,22 +7,9 @@ import os
 
 from subprocess import STDOUT, call, check_output
 
-import click
 
-
-def remove_files(fragment_filenames: list[str], answer_yes: bool) -> None:
-    if not fragment_filenames:
-        return
-
-    if answer_yes:
-        click.echo("Removing the following files:")
-    else:
-        click.echo("I want to remove the following files:")
-
-    for filename in fragment_filenames:
-        click.echo(filename)
-
-    if answer_yes or click.confirm("Is it okay if I remove those files?", default=True):
+def remove_files(fragment_filenames: list[str]) -> None:
+    if fragment_filenames:
         call(["git", "rm", "--quiet"] + fragment_filenames)
 
 
diff --git a/src/towncrier/build.py b/src/towncrier/build.py
index 6dd87ae..3518940 100644
--- a/src/towncrier/build.py
+++ b/src/towncrier/build.py
@@ -15,8 +15,11 @@ from datetime import date
 
 import click
 
+from click import Context, Option
+
+from towncrier import _git
+
 from ._builder import find_fragments, render_fragments, split_fragments
-from ._git import remove_files, stage_newsfile
 from ._project import get_project_name, get_version
 from ._settings import ConfigError, config_option_help, load_config_from_options
 from ._writer import append_to_newsfile
@@ -26,6 +29,18 @@ def _get_date() -> str:
     return date.today().isoformat()
 
 
+def _validate_answer(ctx: Context, param: Option, value: bool) -> bool:
+    value_check = (
+        ctx.params.get("answer_yes")
+        if param.name == "answer_keep"
+        else ctx.params.get("answer_keep")
+    )
+    if value_check and value:
+        click.echo("You can not choose both --yes and --keep at the same time")
+        ctx.abort()
+    return value
+
+
 @click.command(name="build")
 @click.option(
     "--draft",
@@ -67,9 +82,18 @@ def _get_date() -> str:
 @click.option(
     "--yes",
     "answer_yes",
-    default=False,
+    default=None,
     flag_value=True,
     help="Do not ask for confirmation to remove news fragments.",
+    callback=_validate_answer,
+)
+@click.option(
+    "--keep",
+    "answer_keep",
+    default=None,
+    flag_value=True,
+    help="Do not ask for confirmations. But keep news fragments.",
+    callback=_validate_answer,
 )
 def _main(
     draft: bool,
@@ -79,6 +103,7 @@ def _main(
     project_version: str | None,
     project_date: str | None,
     answer_yes: bool,
+    answer_keep: bool,
 ) -> None:
     """
     Build a combined news file from news fragment.
@@ -92,6 +117,7 @@ def _main(
             project_version,
             project_date,
             answer_yes,
+            answer_keep,
         )
     except ConfigError as e:
         print(e, file=sys.stderr)
@@ -106,6 +132,7 @@ def __main(
     project_version: str | None,
     project_date: str | None,
     answer_yes: bool,
+    answer_keep: bool,
 ) -> None:
     """
     The main entry point.
@@ -234,13 +261,43 @@ def __main(
         )
 
         click.echo("Staging newsfile...", err=to_err)
-        stage_newsfile(base_directory, news_file)
+        _git.stage_newsfile(base_directory, news_file)
 
         click.echo("Removing news fragments...", err=to_err)
-        remove_files(fragment_filenames, answer_yes)
+        if should_remove_fragment_files(
+            fragment_filenames,
+            answer_yes,
+            answer_keep,
+        ):
+            _git.remove_files(fragment_filenames)
 
         click.echo("Done!", err=to_err)
 
 
+def should_remove_fragment_files(
+    fragment_filenames: list[str],
+    answer_yes: bool,
+    answer_keep: bool,
+) -> bool:
+    try:
+        if answer_keep:
+            click.echo("Keeping the following files:")
+            # Not proceeding with the removal of the files.
+            return False
+
+        if answer_yes:
+            click.echo("Removing the following files:")
+        else:
+            click.echo("I want to remove the following files:")
+    finally:
+        # Will always be printed, even for answer_keep to help with possible troubleshooting
+        for filename in fragment_filenames:
+            click.echo(filename)
+
+    if answer_yes or click.confirm("Is it okay if I remove those files?", default=True):
+        return True
+    return False
+
+
 if __name__ == "__main__":  # pragma: no cover
     _main()
diff --git a/src/towncrier/newsfragments/129.feature b/src/towncrier/newsfragments/129.feature
new file mode 100644
index 0000000..b36c8fb
--- /dev/null
+++ b/src/towncrier/newsfragments/129.feature
@@ -0,0 +1,2 @@
+Added ``--keep`` option to the ``build`` command that allows to generate a newsfile, but keeps the newsfragments in place.
+This option can not be used together with ``--yes``.


Test patch:
diff --git a/src/towncrier/test/test_build.py b/src/towncrier/test/test_build.py
index 5821342..0a76d91 100644
--- a/src/towncrier/test/test_build.py
+++ b/src/towncrier/test/test_build.py
@@ -407,6 +407,66 @@ class TestCli(TestCase):
             self.assertFalse(os.path.isfile(fragment_path1))
             self.assertFalse(os.path.isfile(fragment_path2))
 
+    @with_isolated_runner
+    def test_keep_fragments(self, runner):
+        """
+        The `--keep` option will build the full final news file
+        without deleting the fragment files and without
+        any extra CLI interaction or confirmation.
+        """
+        setup_simple_project()
+        fragment_path1 = "foo/newsfragments/123.feature"
+        fragment_path2 = "foo/newsfragments/124.feature.rst"
+        with open(fragment_path1, "w") as f:
+            f.write("Adds levitation")
+        with open(fragment_path2, "w") as f:
+            f.write("Extends levitation")
+
+        call(["git", "init"])
+        call(["git", "config", "user.name", "user"])
+        call(["git", "config", "user.email", "user@example.com"])
+        call(["git", "add", "."])
+        call(["git", "commit", "-m", "Initial Commit"])
+
+        result = runner.invoke(_main, ["--date", "01-01-2001", "--keep"])
+
+        self.assertEqual(0, result.exit_code)
+        # The NEWS file is created.
+        # So this is not just `--draft`.
+        self.assertTrue(os.path.isfile("NEWS.rst"))
+        self.assertTrue(os.path.isfile(fragment_path1))
+        self.assertTrue(os.path.isfile(fragment_path2))
+
+    @with_isolated_runner
+    def test_yes_keep_error(self, runner):
+        """
+        It will fail to perform any action when the
+        conflicting --keep and --yes options are provided.
+
+        Called twice with the different order of --keep and --yes options
+        to make sure both orders are validated since click triggers the validator
+        in the order it parses the command line.
+        """
+        setup_simple_project()
+        fragment_path1 = "foo/newsfragments/123.feature"
+        fragment_path2 = "foo/newsfragments/124.feature.rst"
+        with open(fragment_path1, "w") as f:
+            f.write("Adds levitation")
+        with open(fragment_path2, "w") as f:
+            f.write("Extends levitation")
+
+        call(["git", "init"])
+        call(["git", "config", "user.name", "user"])
+        call(["git", "config", "user.email", "user@example.com"])
+        call(["git", "add", "."])
+        call(["git", "commit", "-m", "Initial Commit"])
+
+        result = runner.invoke(_main, ["--date", "01-01-2001", "--yes", "--keep"])
+        self.assertEqual(1, result.exit_code)
+
+        result = runner.invoke(_main, ["--date", "01-01-2001", "--keep", "--yes"])
+        self.assertEqual(1, result.exit_code)
+
     def test_confirmation_says_no(self):
         """
         If the user says "no" to removing the newsfragements, we end up with
@@ -429,7 +489,7 @@ class TestCli(TestCase):
             call(["git", "add", "."])
             call(["git", "commit", "-m", "Initial Commit"])
 
-            with patch("towncrier._git.click.confirm") as m:
+            with patch("towncrier.build.click.confirm") as m:
                 m.return_value = False
                 result = runner.invoke(_main, [])