submodule_roller, roll_util: CC authors on rolls

Add logic to roll_util to extract author, owner, and reviewers from CLs,
and to check whether a given email address has an account in Gerrit.

Use this logic in submodule_roller to allow automatically CCing these
accounts on rolls.

This does not cover all corner cases, notably missing cases where the CL
is submitted on upstream Pigweed but the downstream project uses a
gob-ctl copy on another Gerrit host. That will be fixed before this is
enabled on those builders. (Context: pwrev/30160.)

Change-Id: I80b404ede4530e453322d153b755df380c1d288f
Bug: 353
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/39682
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/recipe_modules/roll_util/api.py b/recipe_modules/roll_util/api.py
index 76d7667..a2f2908 100644
--- a/recipe_modules/roll_util/api.py
+++ b/recipe_modules/roll_util/api.py
@@ -81,8 +81,11 @@
 
 @attr.s
 class Commit(object):
-    hash = attr.ib()
-    message = attr.ib()
+    hash = attr.ib(type=str)
+    message = attr.ib(type=str)
+    author = attr.ib(type=str)
+    owner = attr.ib(type=str)
+    reviewers = attr.ib(type=tuple)
 
 
 @attr.s
@@ -93,8 +96,8 @@
     new_revision = attr.ib(type=str)
     proj_dir = attr.ib(type=str)
     direction = attr.ib(type=str)
-    _commit_data = attr.ib(default=None)
-    _remote = attr.ib(type=str, default=None)
+    commits = attr.ib(type=tuple, default=None)
+    remote = attr.ib(type=str, default=None)
 
     @direction.validator
     def check(self, _, value):  # pragma: no cover
@@ -103,11 +106,11 @@
         if value == _Direction.CURRENT:
             raise ValueError('attempt to do a no-op roll')
 
-    @property
-    def commits(self):
-        if self._commit_data:
-            return self._commit_data
+    def __attrs_post_init__(self):
+        self._set_remote()
+        self._set_commits()
 
+    def _set_commits(self):
         with self._api.context(cwd=self.proj_dir):
             with self._api.step.nest(self.project_name):
                 commits = []
@@ -124,7 +127,7 @@
                         'git log',
                         'log',
                         '{}..{}'.format(base, self.new_revision),
-                        '--pretty=format:%H %B',
+                        '--pretty=format:%H\n%ae\n%B',
                         # Separate entries with null bytes since most entries
                         # will contain newlines ("%B" is the full commit
                         # message, not just the first line.)
@@ -134,17 +137,50 @@
                     .stdout.strip('\0')
                     .split('\0')
                 ):
-                    hash, message = commit.split(' ', 1)
-                    commits.append(Commit(hash, message))
+                    hash, author, message = commit.split('\n', 2)
+                    match = re.search(r'Change-Id: (I\w+)', message)
+                    owner = None
+                    reviewers = []
+                    if match:
+                        change_id = match.group(1)
+                        details = self._api.gerrit.change_details(
+                            'get {}'.format(change_id),
+                            change_id,
+                            host='{}-review.googlesource.com'.format(
+                                self.gerrit_name
+                            ),
+                            test_data=self._api.json.test_api.output(
+                                {
+                                    'owner': {'email': 'owner@example.com'},
+                                    'reviewers': {
+                                        'REVIEWER': [
+                                            {'email': 'reviewer@example.com'},
+                                            {'email': 'nobody@google.com'},
+                                            {
+                                                'email': 'robot@gserviceaccount.com'
+                                            },
+                                        ],
+                                    },
+                                }
+                            ),
+                        ).json.output
+                        owner = details['owner']['email']
+                        for reviewer in details['reviewers']['REVIEWER']:
+                            reviewers.append(reviewer['email'])
 
-                self._commit_data = tuple(commits)
-                return self._commit_data
+                    commits.append(
+                        Commit(
+                            hash=hash,
+                            author=author,
+                            owner=owner,
+                            reviewers=tuple(reviewers),
+                            message=message,
+                        )
+                    )
 
-    @property
-    def remote(self):
-        if self._remote:
-            return self._remote
+                self.commits = tuple(commits)
 
+    def _set_remote(self):
         api = self._api
 
         with api.step.nest('remote'), api.context(cwd=self.proj_dir):
@@ -168,8 +204,7 @@
                 ),
             ).stdout.strip()
 
-            self._remote = api.sso.sso_to_https(remote)
-            return self._remote
+            self.remote = api.sso.sso_to_https(remote)
 
     @property
     def gerrit_name(self):
@@ -202,6 +237,34 @@
         self.labels_to_wait_on = props.labels_to_wait_on
         self.footer = []
 
+    def authors(self, *roll):
+        authors = set()
+        for r in roll:
+            for commit in r.commits:
+                if commit.author:
+                    authors.add(commit.author)
+                if commit.owner:
+                    authors.add(commit.owner)
+        return authors
+
+    def reviewers(self, *roll):
+        reviewers = set()
+        for r in roll:
+            for commit in r.commits:
+                reviewers.update(commit.reviewers)
+        return reviewers
+
+    def can_cc_on_roll(self, email, host=None):
+        # Assume all queried accounts exist on Gerrit in testing except for
+        # nobody@google.com.
+        test_data = self.m.json.test_api.output([{'_account_id': 123}])
+        if email == 'nobody@google.com':
+            test_data = self.m.json.test_api.output([])
+
+        return self.m.gerrit.account_query(
+            email, 'email:{}'.format(email), host=host, test_data=test_data,
+        ).json.output
+
     def _single_commit_roll_message(self, roll):
         template = """
 [roll {project_name}] {sanitized_message}