Repository: jonathanj/cacofonix

Issue:
Coerce issue keys to strings
Towncrier expects issues to be strings and throws an exception if this isn't the case:

```
Traceback (most recent call last):                                                                                                                             │ts after you do a transaction. It takes 12 minutes and your Yoco will restart.", "endDate": "2019-04-05T09:19:00.000Z", "friendlyName": "NFC Announcement - Ba
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_app.py", line 172, in compile_fragment_files                                                         │tch 12 & 13", "heading": "Yoco Tap Update", "imageFileUUID": "1554182327949-7e2239ae-ba89-4081-8b4b-a4a5024a06d0", "imageURL": "https://s3-eu-west-1.amazonaws
    self.config.changelog_output_type)                                                                                                                         │.com/yoco-dev/production/sysadmin-bus-uuid/announcement/1554182327949-7e2239ae-ba89-4081-8b4b-a4a5024a06d0/yoco_tap_icon.png", "isDeleted": false, "isGlobal":
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_towncrier.py", line 63, in render_fragment                                                           │ false, "lastUpdated": "2019-04-02T05:19:46.504Z", "nonDismissible": false, "primaryButtonLink": "http://bit.ly/yocotapbatch6", "primaryButtonText": "Read Mor
    for ticket, url in sorted(issues.items())))                                                                                                                │e", "showIfAndroidVersionLessThan": "9.9.9", "showIfIOSVersionLessThan": "9.9.9", "startDate": "2019-04-02T11:30:00.000Z", "targetingCriteria": {"dismissibleA
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_towncrier.py", line 63, in <genexpr>                                                                 │tUserLevel": false}, "uuid": "1554182386490-5097b263-23db-4460-9610-83e928f425ba", "wasImmutable": true}
    for ticket, url in sorted(issues.items())))                                                                                                                │ LOG  Announcements> Selector> Announcement after its end date
  File "/usr/local/lib/python3.7/site-packages/cacofonix/_towncrier.py", line 35, in _ticket_prefix                                                            │ LOG  Announcements> announcement>  {"created": "2019-09-19T13:21:38.531Z", "description": "Thank you for purchasing a Yoco Go card machine. Please update you
    if ticket.isdigit():                                                                                                                                       │r app in order to use your new card machine. Happy transacting!", "endDate": "2019-09-27T13:17:00.000Z", "friendlyName": "New Go Force Update - test", "headin
AttributeError: 'int' object has no attribute 'isdigit'```

Discussion:

Patch:
diff --git a/src/cacofonix/_app.py b/src/cacofonix/_app.py
index 6b1e7cd..5f867dc 100644
--- a/src/cacofonix/_app.py
+++ b/src/cacofonix/_app.py
@@ -37,7 +37,9 @@ class Application(object):
         """
         Parse and validate a fragment from a stream.
         """
-        return self.validate_fragment(_yaml.load(fd))
+        fragment = _yaml.load(fd)
+        fragment['issues'] = {str(k): v for k, v in fragment.get('issues', {}).items()}
+        return self.validate_fragment(fragment)
 
     def find_fragments(
             self,
@@ -157,12 +159,12 @@ class Application(object):
     def compile_fragment_files(
             self,
             write_fs: FS,
-            found_fragments: Iterable[FoundFragment]) -> int:
+            found_fragments: Iterable[FoundFragment]) -> List[str]:
         """
         Compile fragment files into `parent_dir`.
         """
-        n = 0
-        for n, (version_fs, filename) in enumerate(found_fragments, 1):
+        outputs = []
+        for version_fs, filename in found_fragments:
             try:
                 fragment = self.load_fragment(version_fs.readtext(filename))
                 fragment_type = fragment.get('type')
@@ -186,9 +188,10 @@ class Application(object):
                     if parent_dir:
                         write_fs.makedirs(parent_dir, recreate=True)
                     write_fs.writetext(output_path, rendered_content)
+                    outputs.append(output_path)
             except Exception:
                 raise FragmentCompilationError(filename)
-        return n
+        return outputs
 
     def render_changelog(
             self,
diff --git a/src/cacofonix/_config.py b/src/cacofonix/_config.py
index 46d4528..3c9c3a5 100644
--- a/src/cacofonix/_config.py
+++ b/src/cacofonix/_config.py
@@ -10,11 +10,12 @@ T = TypeVar('T')
 default_sections = OrderedDict([('', '')])
 
 default_fragment_types = OrderedDict([
-    (u'feature', {'name': u'Added', 'showcontent': True}),
-    (u'bugfix', {'name': u'Fixed', 'showcontent': True}),
-    (u'doc', {'name': u'Documentation', 'showcontent': True}),
-    (u'removal', {'name': u'Removed', 'showcontent': True}),
-    (u'misc', {'name': u'Misc', 'showcontent': False}),
+    (u'feature', {'title': u'Added', 'showcontent': True}),
+    (u'change', {'title': u'Changed', 'showcontent': True}),
+    (u'bugfix', {'title': u'Fixed', 'showcontent': True}),
+    (u'doc', {'title': u'Documentation', 'showcontent': True}),
+    (u'removal', {'title': u'Removed', 'showcontent': True}),
+    (u'misc', {'title': u'Misc', 'showcontent': False}),
 ])
 
 
diff --git a/src/cacofonix/main.py b/src/cacofonix/main.py
index 47417d9..84bd8f5 100644
--- a/src/cacofonix/main.py
+++ b/src/cacofonix/main.py
@@ -231,7 +231,7 @@ def compile(app: Application,
     new_fragments = list(app.find_new_fragments())
 
     with open_fs('temp://') as tmp_fs:
-        n = app.compile_fragment_files(tmp_fs, new_fragments)
+        n = len(app.compile_fragment_files(tmp_fs, new_fragments))
         echo('Found {} new changelog fragments'.format(n))
         changelog = app.render_changelog(
             fs=tmp_fs,


Test patch:
diff --git a/src/cacofonix/test/data/config.yaml b/src/cacofonix/test/data/config.yaml
new file mode 100644
index 0000000..3570806
--- /dev/null
+++ b/src/cacofonix/test/data/config.yaml
@@ -0,0 +1,10 @@
+# Path in which to find fragments.
+change_fragments_path: fragments
+# Path to the changelog to merge into.
+changelog_path: CHANGELOG.md
+# Marker to add new changes below.
+changelog_marker: |
+  <!-- Generated release notes start. -->
+
+# Type of document to output, valid values are: markdown, rest
+changelog_output_type: markdown
diff --git a/src/cacofonix/test/data/fragments/numeric_issue_number.yaml b/src/cacofonix/test/data/fragments/numeric_issue_number.yaml
new file mode 100644
index 0000000..545e1dc
--- /dev/null
+++ b/src/cacofonix/test/data/fragments/numeric_issue_number.yaml
@@ -0,0 +1,7 @@
+type: bugfix
+section: ''
+issues:
+  1234: https://example.com/
+feature_flags: []
+description: |-
+  An issue description.
diff --git a/src/cacofonix/test/test_app.py b/src/cacofonix/test/test_app.py
new file mode 100644
index 0000000..83d8f72
--- /dev/null
+++ b/src/cacofonix/test/test_app.py
@@ -0,0 +1,66 @@
+import os.path
+import pytest
+from fs import open_fs
+from fs.base import FS
+from fs.wrap import read_only
+
+from cacofonix._app import Application
+from cacofonix._config import Config
+from cacofonix._effects import SideEffects
+
+
+class MockSideEffects(SideEffects):
+    def __init__(self, root_fs, config):
+        self.root_fs = root_fs
+        self.fragments_fs = self.root_fs.opendir(config.change_fragments_path)
+
+    def archive_fs(self, path: str) -> FS:
+        raise NotImplementedError()
+
+    def changelog_fs(self, path: str) -> FS:
+        raise NotImplementedError()
+
+    def git_mv(self, path: str) -> FS:
+        raise NotImplementedError()
+
+    def git_stage(self, path: str) -> FS:
+        raise NotImplementedError()
+
+
+def open_test_root_fs() -> FS:
+    """
+    Open the filesystem root for the tests.
+    """
+    cwd = os.path.dirname(__file__)
+    return read_only(open_fs('data', cwd=cwd))
+
+
+def load_test_config(root_fs) -> Config:
+    """
+    Load the config files for the tests.
+    """
+    with root_fs.open('config.yaml') as fd:
+        return Config.parse(fd)
+
+
+class TestCompileFragmentFiles:
+    """
+    Tests for `Application.compile_fragment_files`.
+    """
+    def test_numeric_issue_key(self):
+        """
+        Issues with numeric keys can be compiled.
+        """
+        with open_test_root_fs() as root_fs:
+            config = load_test_config(root_fs)
+            effects = MockSideEffects(root_fs, config)
+            app = Application(config, effects)
+            found_fragments = [
+                (effects.fragments_fs, 'numeric_issue_number.yaml'),
+            ]
+            with open_fs('temp://') as write_fs:
+                outputs = app.compile_fragment_files(
+                    write_fs,
+                    found_fragments)
+                assert len(outputs) == 1
+                assert '#1234' in write_fs.readtext(outputs[0])