tools: owners: initial parsing of OWNERS file

Tested:

```
$ tools/owners reviewers
{"reviewer": "geissonator@yahoo.com", "state": "REVIEWER"}
{"reviewer": "joel@jms.id.au", "state": "REVIEWER"}
{"reviewer": "patrick@stwcx.xyz", "state": "REVIEWER"}
{"reviewer": "andrew@aj.id.au", "state": "CC"}

$ tools/owners reviewers --push-args
r=geissonator@yahoo.com,r=joel@jms.id.au,r=patrick@stwcx.xyz,cc=andrew@aj.id.au
```

Signed-off-by: Patrick Williams <patrick@stwcx.xyz>
Change-Id: I145bd065dff101776c733fddb26fea4f324dd100
diff --git a/tools/owners b/tools/owners
new file mode 100755
index 0000000..0385b2e
--- /dev/null
+++ b/tools/owners
@@ -0,0 +1,104 @@
+#!/bin/env python3
+import argparse
+import json
+import yaml
+
+from typing import List, TypedDict
+from yaml.loader import SafeLoader
+
+# A list of Gerrit users (email addresses).
+UsersList = List[str]
+
+# A YAML node with an extra line number.
+class NumberedNode(TypedDict):
+    line_number: int
+
+
+# The root YAML node of an OWNERS file
+class OwnersData(NumberedNode, TypedDict, total=False):
+    owners: UsersList
+    reviewers: UsersList
+
+
+# A YAML loader that adds the start line number onto each node (for
+# later linting support)
+class YamlLoader(SafeLoader):
+    def construct_mapping(
+        self, node: yaml.nodes.Node, deep: bool = False
+    ) -> NumberedNode:
+        mapping: NumberedNode = super(YamlLoader, self).construct_mapping(
+            node, deep=deep
+        )  # type: ignore
+        mapping["line_number"] = node.start_mark.line + 1
+        return mapping
+
+    # Load a file and return the OwnersData.
+    @staticmethod
+    def load(file: str) -> OwnersData:
+        data: OwnersData
+        with open(file, "r") as f:
+            data = yaml.load(f, Loader=YamlLoader)
+        return data
+
+
+# Class to match commit information with OWNERS files.
+# TODO: git commit piece not yet supported.
+class CommitMatch:
+    def __init__(self, owners: OwnersData):
+        self.data = owners
+
+    def owners(self) -> UsersList:
+        return self.data["owners"] if "owners" in self.data else []
+
+    def reviewers(self) -> UsersList:
+        return self.data["reviewers"] if "reviewers" in self.data else []
+
+
+# The subcommand to get the reviewers.
+def subcmd_reviewers(args: argparse.Namespace, data: OwnersData) -> None:
+    matcher = CommitMatch(data)
+
+    # Print in `git push refs/for/branch%<reviewers>` format.
+    if args.push_args:
+        result = []
+        for o in matcher.owners():
+            # Gerrit uses 'r' for the required reviewers (owners).
+            result.append(f"r={o}")
+        for r in matcher.reviewers():
+            # Gerrit uses 'cc' for the optional reviewers.
+            result.append(f"cc={r}")
+        print(",".join(result))
+    # Print as Gerrit Add Reviewers POST format.
+    # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer
+    else:
+        for o in matcher.owners():
+            print(json.dumps({"reviewer": o, "state": "REVIEWER"}))
+        for r in matcher.reviewers():
+            print(json.dumps({"reviewer": r, "state": "CC"}))
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "-p", "--path", default=".", help="Root path to analyse"
+    )
+    subparsers = parser.add_subparsers()
+
+    parser_reviewers = subparsers.add_parser(
+        "reviewers", help="Generate List of Reviewers"
+    )
+    parser_reviewers.add_argument(
+        "--push-args",
+        action=argparse.BooleanOptionalAction,
+        help="Format as git push options",
+    )
+    parser_reviewers.set_defaults(func=subcmd_reviewers)
+
+    args = parser.parse_args()
+
+    file = YamlLoader.load(args.path + "/OWNERS")
+    args.func(args, file)
+
+
+if __name__ == "__main__":
+    main()