llvm_tools: Unify patch json representation

At present, I made a mistake where patch_utils expected
certain keys to exist. However, the original patch_manager.py
didn't. This was further broken by the fact that patch_sync.py
would avoid serializing the 'platforms' field if empty, causing
a back and forth between patch_utils.py and patch_sync.

Update the unittests to match and verify we're doing the correct
thing.

BUG=None
TEST=./patch_utils_unittest.py
TEST=./patch_manager_unittest.py

Change-Id: Ib02c9d552848831f395b006de9a28ea4292b82f7
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/toolchain-utils/+/3704542
Tested-by: Jordan Abrahams-Whitehead <ajordanr@google.com>
Reviewed-by: George Burgess <gbiv@chromium.org>
Commit-Queue: Jordan Abrahams-Whitehead <ajordanr@google.com>
diff --git a/llvm_tools/patch_manager.py b/llvm_tools/patch_manager.py
index 51a7476..b499d52 100755
--- a/llvm_tools/patch_manager.py
+++ b/llvm_tools/patch_manager.py
@@ -512,6 +512,8 @@
   for pe in patch_entries:
     test_result = pe.test_apply(llvm_src_dir)
     if not test_result:
+      if pe.version_range is None:
+        pe.version_range = {}
       pe.version_range['until'] = svn_version
       modified_entries.append(pe)
     else:
diff --git a/llvm_tools/patch_utils.py b/llvm_tools/patch_utils.py
index 6fd75c3..cdf9f21 100644
--- a/llvm_tools/patch_utils.py
+++ b/llvm_tools/patch_utils.py
@@ -145,10 +145,10 @@
   """Object mapping of an entry of PATCHES.json."""
   workdir: Path
   """Storage location for the patches."""
-  metadata: Dict[str, Any]
-  platforms: List[str]
+  metadata: Optional[Dict[str, Any]]
+  platforms: Optional[List[str]]
   rel_patch_path: str
-  version_range: Dict[str, Optional[int]]
+  version_range: Optional[Dict[str, Optional[int]]]
   _parsed_hunks = None
 
   def __post_init__(self):
@@ -159,33 +159,29 @@
   def from_dict(cls, workdir: Path, data: Dict[str, Any]):
     """Instatiate from a dictionary.
 
-    Dictionary must have at least the following keys:
+    Dictionary must have at least the following key:
 
       {
-        'metadata': {
-          'title': '<title>'
-        },
-        'platforms': ['<platform>'],
         'rel_patch_path': '<relative patch path to workdir>',
-        'version_range': {
-          'from': <int>,
-          'until': <int>,
-        },
       }
 
     Returns:
       A new PatchEntry.
     """
-    return cls(workdir, data['metadata'], data['platforms'],
-               data['rel_patch_path'], data['version_range'])
+    return cls(workdir, data.get('metadata'), data.get('platforms'),
+               data['rel_patch_path'], data.get('version_range'))
 
   def to_dict(self) -> Dict[str, Any]:
-    return {
+    out = {
         'metadata': self.metadata,
-        'platforms': self.platforms,
         'rel_patch_path': self.rel_patch_path,
         'version_range': self.version_range,
     }
+    if self.platforms:
+      # To match patch_sync, only serialized when
+      # non-empty and non-null.
+      out['platforms'] = sorted(self.platforms)
+    return out
 
   def parsed_hunks(self) -> Dict[str, List[Hunk]]:
     # Minor caching here because IO is slow.
@@ -200,6 +196,8 @@
   def can_patch_version(self, svn_version: int) -> bool:
     """Is this patch meant to apply to `svn_version`?"""
     # Sometimes the key is there, but it's set to None.
+    if not self.version_range:
+      return True
     from_v = self.version_range.get('from') or 0
     until_v = self.version_range.get('until')
     if until_v is None:
@@ -208,6 +206,8 @@
 
   def is_old(self, svn_version: int) -> bool:
     """Is this patch old compared to `svn_version`?"""
+    if not self.version_range:
+      return False
     until_v = self.version_range.get('until')
     # Sometimes the key is there, but it's set to None.
     if until_v is None:
@@ -245,7 +245,9 @@
     return self.apply(root_dir, ['--dry-run'])
 
   def title(self) -> str:
-    return self.metadata['title']
+    if not self.metadata:
+      return ''
+    return self.metadata.get('title', '')
 
 
 def json_to_patch_entries(workdir: Path, json_fd: IO[str]) -> List[PatchEntry]:
diff --git a/llvm_tools/patch_utils_unittest.py b/llvm_tools/patch_utils_unittest.py
index bef5ae5..3a6409b 100755
--- a/llvm_tools/patch_utils_unittest.py
+++ b/llvm_tools/patch_utils_unittest.py
@@ -5,6 +5,7 @@
 
 """Unit tests for the patch_utils.py file."""
 
+import io
 from pathlib import Path
 import tempfile
 import unittest
@@ -84,6 +85,33 @@
     self.assertTrue(e5.can_patch_version(5))
     self.assertFalse(e5.can_patch_version(9))
 
+  def test_can_parse_from_json(self):
+    """Test that patches be loaded from json."""
+    json = """
+[
+  {
+    "metadata": {},
+    "platforms": [],
+    "rel_patch_path": "cherry/nowhere.patch",
+    "version_range": {}
+  },
+  {
+    "metadata": {},
+    "rel_patch_path": "cherry/somewhere.patch",
+    "version_range": {}
+  },
+  {
+    "rel_patch_path": "where.patch",
+    "version_range": null
+  },
+  {
+    "rel_patch_path": "cherry/anywhere.patch"
+  }
+]
+    """
+    result = pu.json_to_patch_entries(Path(), io.StringIO(json))
+    self.assertEqual(len(result), 4)
+
   def test_parsed_hunks(self):
     """Test that we can parse patch file hunks."""
     m = mock.mock_open(read_data=_EXAMPLE_PATCH)
@@ -137,7 +165,7 @@
         'metadata': {
             'title': 'hello world',
         },
-        'platforms': [],
+        'platforms': ['a'],
         'rel_patch_path': 'x/y/z',
         'version_range': {
             'from': 4,