roll_util: Support multi-roll commit messages

Support roll messages for rolls of multiple submodules or repo projects.
Not yet used.

Change-Id: I8a5c4c5b3e141835e720cfd16e77f9122717b3e6
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/22404
Commit-Queue: Rob Mohr <mohrr@google.com>
Reviewed-by: Oliver Newman <olivernewman@google.com>
diff --git a/recipe_modules/roll_util/api.py b/recipe_modules/roll_util/api.py
index 96f8e5b..27d10b7 100644
--- a/recipe_modules/roll_util/api.py
+++ b/recipe_modules/roll_util/api.py
@@ -24,6 +24,7 @@
 ESCAPE_TAGS = [
     'Bug:',
     'Fixed:',
+    'Requires:',
     'Reviewed-on:',
 ]
 
@@ -64,6 +65,65 @@
     message = attr.ib()
 
 
+@attr.s
+class Roll(object):
+    _api = attr.ib()
+    project_name = attr.ib(type=str)
+    old_revision = attr.ib(type=str)
+    new_revision = attr.ib(type=str)
+    proj_dir = attr.ib(type=str)
+    _commit_data = attr.ib(default=None)
+
+    @property
+    def commits(self):
+        if self._commit_data:
+            return self._commit_data
+
+        with self._api.context(cwd=self.proj_dir):
+            with self._api.step.nest(self.project_name):
+                commits = []
+                if _is_hash(self.old_revision):
+                    base = self.old_revision
+                else:
+                    base = '{}~5'.format(self.new_revision)
+
+                for commit in (
+                    self._api.git(
+                        'git log',
+                        'log',
+                        '{}..{}'.format(base, self.new_revision),
+                        '--pretty=format:%H %B',
+                        # Separate entries with null bytes since most entries
+                        # will contain newlines ("%B" is the full commit
+                        # message, not just the first line.)
+                        '-z',
+                        stdout=self._api.raw_io.output(),
+                    )
+                    .stdout.strip('\0')
+                    .split('\0')
+                ):
+                    hash, message = commit.split(' ', 1)
+                    commits.append(Commit(hash, message))
+
+                self._commit_data = tuple(commits)
+                return self._commit_data
+
+
+@attr.s
+class Message(object):
+    name = attr.ib(type=str)
+    template = attr.ib(type=str)
+    kwargs = attr.ib(type=dict)
+    num_commits = attr.ib(type=int)
+    footer = attr.ib(type=tuple, default=())
+
+    def render(self, with_footer=True):
+        result = [self.template.format(**self.kwargs)]
+        if with_footer:
+            result.extend(x for x in self.footer)
+        return '\n'.join(result)
+
+
 def _is_hash(value):
     return re.match(r'^[0-9a-fA-F]{40}', value)
 
@@ -73,110 +133,117 @@
         super(RollUtilApi, self).__init__(*args, **kwargs)
         self.labels_to_set = {x.label: x.value for x in props.labels_to_set}
         self.labels_to_wait_on = props.labels_to_wait_on
+        self.footer = ['CQ-Do-Not-Cancel-Tryjobs: true']
 
-    def _single_commit_roll_message(
-        self, project_name, commit, old_revision, new_revision
-    ):
+    def _single_commit_roll_message(self, roll):
         template = """
 [roll {project_name}] {sanitized_message}
 
-Rolled-Commits: {old_revision:.15}..{new_revision:.15}
-CQ-Do-Not-Cancel-Tryjobs: true
-    """.strip()
+{project_name} Rolled-Commits: {old_revision:.15}..{new_revision:.15}
+        """.strip()
+
+        commit = roll.commits[0]
 
         kwargs = {
-            'project_name': project_name,
+            'project_name': roll.project_name,
             'original_message': commit.message,
             'sanitized_message': _sanitize_message(commit.message),
-            'old_revision': old_revision,
-            'new_revision': new_revision,
+            'old_revision': roll.old_revision,
+            'new_revision': roll.new_revision,
         }
 
-        message = template.format(**kwargs)
+        message = Message(
+            name=roll.project_name,
+            template=template,
+            kwargs=kwargs,
+            num_commits=1,
+            footer=tuple(self.footer),
+        )
 
-        with self.m.step.nest('message') as pres:
+        with self.m.step.nest(
+            'message for {}'.format(roll.project_name)
+        ) as pres:
             pres.logs['template'] = template
             pres.logs['kwargs'] = pprint.pformat(kwargs)
-            pres.logs['message'] = message
+            pres.logs['message'] = message.render()
 
         return message
 
-    def _multiple_commits_roll_message(
-        self, project_name, commits, old_revision, new_revision
-    ):
+    def _multiple_commits_roll_message(self, roll):
         template = """
-[{project_name}] Roll {number} commits
+[{project_name}] Roll {num_commits} commits
 
 {one_liners}
 
-Rolled-Commits: {old_revision:.15}..{new_revision:.15}
-CQ-Do-Not-Cancel-Tryjobs: true
+{project_name} Rolled-Commits: {old_revision:.15}..{new_revision:.15}
     """.strip()
 
         one_liners = [
             '{:.15} {}'.format(commit.hash, commit.message.splitlines()[0][:50])
-            for commit in commits
+            for commit in roll.commits
         ]
 
-        number = len(commits)
-        if not _is_hash(old_revision):
-            number = 'multiple'
+        num_commits = len(roll.commits)
+        if not _is_hash(roll.old_revision):
+            num_commits = 'multiple'
             one_liners.append('...')
 
         kwargs = {
-            'project_name': project_name,
-            'number': number,
+            'project_name': roll.project_name,
+            'num_commits': num_commits,
             'one_liners': '\n'.join(one_liners),
-            'old_revision': old_revision,
-            'new_revision': new_revision,
+            'old_revision': roll.old_revision,
+            'new_revision': roll.new_revision,
         }
 
-        message = template.format(**kwargs)
+        message = Message(
+            name=roll.project_name,
+            template=template,
+            kwargs=kwargs,
+            num_commits=num_commits,
+            footer=tuple(self.footer),
+        )
 
         with self.m.step.nest('message') as pres:
             pres.logs['template'] = template
             pres.logs['kwargs'] = pprint.pformat(kwargs)
-            pres.logs['message'] = message
+            pres.logs['message'] = message.render()
 
         return message
 
-    def _commits(self, proj_dir, old_revision, new_revision):
-        with self.m.context(cwd=proj_dir):
-            commits = []
-            if _is_hash(old_revision):
-                base = old_revision
-            else:
-                base = '{}~5'.format(new_revision)
+    def _single_roll_message(self, roll):
+        if len(roll.commits) > 1:
+            return self._multiple_commits_roll_message(roll)
+        return self._single_commit_roll_message(roll)
 
-            for commit in (
-                self.m.git(
-                    'git log',
-                    'log',
-                    '{}..{}'.format(base, new_revision),
-                    '--pretty=format:%H %B',
-                    '-z',
-                    stdout=self.m.raw_io.output(),
-                )
-                .stdout.strip('\0')
-                .split('\0')
-            ):
-                hash, message = commit.split(' ', 1)
-                commits.append(Commit(hash, message))
-            return commits
 
-    def message(self, project_name, proj_dir, old_revision, new_revision):
+    def _multiple_rolls_message(self, *rolls):
+        rolls = sorted(rolls, key=lambda x: x.project_name)
+
+        messages = []
+        for roll in rolls:
+            messages.append(self._single_roll_message(roll))
+
+        texts = [
+            '[roll {}] Roll {} commits'.format(
+                ', '.join(x.name for x in messages),
+                sum(x.num_commits for x in messages),
+            )
+        ]
+        texts.extend(x.render(with_footer=False) for x in messages)
+        texts.append('\n'.join('{}'.format(x) for x in self.footer))
+
+        return '\n\n'.join(texts)
+
+    def Roll(self, **kwargs):
+        """Create a Roll. See Roll class above for details."""
+        return Roll(api=self.m, **kwargs)
+
+    def message(self, *rolls):
         with self.m.step.nest('roll message'):
-            commits = self._commits(proj_dir, old_revision, new_revision)
-
-            if len(commits) > 1:
-                return self._multiple_commits_roll_message(
-                    project_name, commits, old_revision, new_revision
-                )
-
-            else:
-                return self._single_commit_roll_message(
-                    project_name, commits[0], old_revision, new_revision
-                )
+            if len(rolls) > 1:
+                return self._multiple_rolls_message(*rolls)
+            return self._single_roll_message(*rolls).render()
 
     def check_roll_direction(
         self, git_dir, old, new, name='check roll direction'