pw_presubmit: Add triggering change info to ctx

Add information about the triggering changes to PresubmitContext
objects.

Bug: b/265795959
Change-Id: I5d0ab347d1ee075aa69f886cf4dc9923a2d98dfd
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/127034
Reviewed-by: Ted Pudlik <tpudlik@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
diff --git a/pw_presubmit/docs.rst b/pw_presubmit/docs.rst
index f86a132..f6ad4d4 100644
--- a/pw_presubmit/docs.rst
+++ b/pw_presubmit/docs.rst
@@ -132,6 +132,7 @@
 * ``builder``: The builder being run
 * ``swarming_task_id``: The swarming task id of this build
 * ``pipeline``: Information about the build pipeline, if applicable.
+* ``triggers``: Information about triggering commits, if applicable.
 
 The ``pipeline`` member, if present, is of type ``LuciPipeline`` and has the
 following members:
@@ -140,6 +141,19 @@
 * ``builds_from_previous_iteration``: A list of the buildbucket ids from the
   previous round, if any.
 
+The ``triggers`` member is a sequence of ``LuciTrigger`` objects, which have the
+following members:
+
+* ``number``: The number of the change in Gerrit.
+* ``patchset``: The number of the patchset of the change.
+* ``remote``: The full URL of the remote.
+* ``branch``: The name of the branch on which this change is being/was
+  submitted.
+* ``ref``: The ``refs/changes/..`` path that can be used to reference the
+  patch for unsubmitted changes and the hash for submitted changes.
+* ``gerrit_name``: The name of the googlesource.com Gerrit host.
+* ``submitted``: Whether the change has been submitted or is still pending.
+
 Additional members can be added by subclassing ``PresubmitContext`` and
 ``Presubmit``. Then override ``Presubmit._create_presubmit_context()`` to
 return the subclass of ``PresubmitContext``. Finally, add
diff --git a/pw_presubmit/py/pw_presubmit/presubmit.py b/pw_presubmit/py/pw_presubmit/presubmit.py
index 0928218..24f5309 100644
--- a/pw_presubmit/py/pw_presubmit/presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/presubmit.py
@@ -216,6 +216,24 @@
     round: int
     builds_from_previous_iteration: Sequence[int]
 
+    @staticmethod
+    def create(bbid: int) -> Optional['LuciPipeline']:
+        pipeline_props = (
+            get_buildbucket_info(bbid)
+            .get('input', {})
+            .get('properties', {})
+            .get('$pigweed/pipeline', {})
+        )
+        if not pipeline_props.get('inside_a_pipeline', False):
+            return None
+
+        return LuciPipeline(
+            round=int(pipeline_props['round']),
+            builds_from_previous_iteration=[
+                int(x) for x in pipeline_props['builds_from_previous_iteration']
+            ],
+        )
+
 
 def get_buildbucket_info(bbid) -> Dict[str, Any]:
     if not bbid or not shutil.which('bb'):
@@ -228,6 +246,52 @@
 
 
 @dataclasses.dataclass
+class LuciTrigger:
+    """Details the pending change or submitted commit triggering the build."""
+
+    number: int
+    remote: str
+    branch: str
+    ref: str
+    gerrit_name: str
+    submitted: bool
+
+    @property
+    def gerrit_url(self):
+        if not self.number:
+            return self.gitiles_url
+        return 'https://{}-review.googlesource.com/c/{}'.format(
+            self.gerrit_name, self.number
+        )
+
+    @property
+    def gitiles_url(self):
+        return '{}/+/{}'.format(self.remote, self.ref)
+
+    @staticmethod
+    def create_from_environment(
+        env: Optional[Dict[str, str]] = None,
+    ) -> Sequence['LuciTrigger']:
+        if not env:
+            env = os.environ.copy()
+        raw_path = env.get('TRIGGERING_CHANGES_JSON')
+        if not raw_path:
+            return ()
+        path = Path(raw_path)
+        if not path.is_file():
+            return ()
+
+        result = []
+        with open(path, 'r') as ins:
+            for trigger in json.load(ins):
+                keys = set('number remote branch ref gerrit_name submitted')
+                if keys <= trigger.keys():
+                    result.append(LuciTrigger(**{x: trigger[x] for x in keys}))
+
+        return tuple(result)
+
+
+@dataclasses.dataclass
 class LuciContext:
     """LUCI-specific information about the environment."""
 
@@ -238,55 +302,49 @@
     builder: str
     swarming_task_id: str
     pipeline: Optional[LuciPipeline]
+    triggers: Sequence[LuciTrigger] = dataclasses.field(default_factory=tuple)
 
     @staticmethod
-    def create_from_environment():
+    def create_from_environment(
+        env: Optional[Dict[str, str]] = None
+    ) -> Optional['LuciContext']:
         """Create a LuciContext from the environment."""
+
+        if not env:
+            env = os.environ.copy()
+
         luci_vars = [
             'BUILDBUCKET_ID',
             'BUILDBUCKET_NAME',
             'BUILD_NUMBER',
             'SWARMING_TASK_ID',
         ]
-        if any(x for x in luci_vars if x not in os.environ):
+        if any(x for x in luci_vars if x not in env):
             return None
 
-        project, bucket, builder = os.environ['BUILDBUCKET_NAME'].split(':')
+        project, bucket, builder = env['BUILDBUCKET_NAME'].split(':')
 
         bbid: int = 0
         pipeline: Optional[LuciPipeline] = None
         try:
-            bbid = int(os.environ['BUILDBUCKET_ID'])
-
-            pipeline_props = (
-                get_buildbucket_info(bbid)
-                .get('input', {})
-                .get('properties', {})
-                .get('$pigweed/pipeline', {})
-            )
-            if pipeline_props.get('inside_a_pipeline', False):
-                pipeline = LuciPipeline(
-                    round=int(pipeline_props['round']),
-                    builds_from_previous_iteration=[
-                        int(x)
-                        for x in pipeline_props[
-                            'builds_from_previous_iteration'
-                        ]
-                    ],
-                )
+            bbid = int(env['BUILDBUCKET_ID'])
+            pipeline = LuciPipeline.create(bbid)
 
         except ValueError:
             pass
 
-        return LuciContext(
+        result = LuciContext(
             buildbucket_id=bbid,
-            build_number=int(os.environ['BUILD_NUMBER']),
+            build_number=int(env['BUILD_NUMBER']),
             project=project,
             bucket=bucket,
             builder=builder,
-            swarming_task_id=os.environ['SWARMING_TASK_ID'],
+            swarming_task_id=env['SWARMING_TASK_ID'],
             pipeline=pipeline,
+            triggers=LuciTrigger.create_from_environment(env),
         )
+        _LOG.debug('%r', result)
+        return result
 
 
 @dataclasses.dataclass