Initial version of resultdb reporter  module.

This is the first version of the resultdb reporter module. It has basic
functionality for uploading test results to resultsdb but it will keep
evolving to upload entire files.

Change-Id: Iad364607593c70643f87bacd01eae2f1c7c87d4c
Reviewed-on: https://flutter-review.googlesource.com/c/recipes/+/56400
Reviewed-by: Ricardo Amador <ricardoamador@google.com>
Commit-Queue: Godofredo Contreras <godofredoc@google.com>
diff --git a/recipe_modules/resultdb_reporter/__init__.py b/recipe_modules/resultdb_reporter/__init__.py
new file mode 100644
index 0000000..cc632c4
--- /dev/null
+++ b/recipe_modules/resultdb_reporter/__init__.py
@@ -0,0 +1,11 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+DEPS = [
+    "fuchsia/buildbucket_util",
+    "recipe_engine/raw_io",
+    "recipe_engine/resultdb",
+    "recipe_engine/runtime",
+    "recipe_engine/step",
+]
diff --git a/recipe_modules/resultdb_reporter/api.py b/recipe_modules/resultdb_reporter/api.py
new file mode 100644
index 0000000..c4361eb
--- /dev/null
+++ b/recipe_modules/resultdb_reporter/api.py
@@ -0,0 +1,76 @@
+# Copyright 2024 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import json
+from recipe_engine import recipe_api
+from PB.go.chromium.org.luci.resultdb.proto.v1.test_result import TestStatus
+
+
+class PreparedResult:
+  """Represents a test result that will be uploaded to resultdb."""
+
+  def __init__(self, api, test_id, summary, status, resultdb_resource):
+    self._api = api
+    self._test_id = test_id
+    self._summary = summary
+    self._status = status
+    self._resultdb_resource = resultdb_resource
+
+  def upload(self):
+    """
+        Uploads the preparedResult to resultdb.
+
+        This operation creates a step at the current nesting level.
+        Logs set in the prepared step are linked as part of the summary.
+        """
+    step_name = "upload to resultdb"
+
+    if not self._api.resultdb.enabled:
+      self._api.step.empty(
+          step_name,
+          status=self._api.step.INFRA_FAILURE,
+          step_text="ResultDB integration was not enabled for this build",
+          raise_on_failure=False,
+      )
+      return
+
+    expected = self._status == TestStatus.PASS
+
+    test_result = {
+        "testId": self._test_id,
+        "expected": expected,
+        "summaryHtml": self._summary,
+        "status": self._status,
+    }
+
+    cmd = [
+        "vpython3",
+        self._resultdb_resource,
+        json.dumps(test_result),
+    ]
+
+    self._api.step(
+        step_name,
+        self._api.resultdb.wrap(cmd),
+        infra_step=True,
+    )
+
+
+class ResultdbReporterApi(recipe_api.RecipeApi):
+  """ResultdbReporterApi provides functionality to upload test results
+    to resultsdb.
+    """
+
+  def report_result(self, test_id, summary, status):
+    """
+        Uploads a single test result to resultsdb.
+        Args:
+            test_id (str): test id to be used by resultdb.
+            summary (string): The summary of the test result.
+            status (TestStatus): The pass/failed/skipped status of the test result.
+        """
+    prepared_result = PreparedResult(
+        self.m, test_id, summary, status, self.resource("resultdb.py")
+    )
+    prepared_result.upload()
diff --git a/recipe_modules/resultdb_reporter/resources/resultdb.py b/recipe_modules/resultdb_reporter/resources/resultdb.py
new file mode 100644
index 0000000..b9f53e4
--- /dev/null
+++ b/recipe_modules/resultdb_reporter/resources/resultdb.py
@@ -0,0 +1,118 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# [VPYTHON:BEGIN]
+# python_version: "3.8"
+# wheel: <
+#   name: "infra/python/wheels/idna-py2_py3"
+#   version: "version:2.10"
+# >
+# wheel: <
+#   name: "infra/python/wheels/urllib3-py2_py3"
+#   version: "version:1.26.4"
+# >
+# wheel: <
+#   name: "infra/python/wheels/certifi-py2_py3"
+#   version: "version:2020.12.5"
+# >
+# wheel: <
+#   name: "infra/python/wheels/chardet-py2_py3"
+#   version: "version:4.0.0"
+# >
+# wheel: <
+#   name: "infra/python/wheels/requests-py2_py3"
+#   version: "version:2.25.1"
+# >
+# [VPYTHON:END]
+
+import json
+import argparse
+import os
+import sys
+import requests
+
+
+def upload_results(test_result, url, auth_token):
+    res = requests.post(
+        url,
+        headers={
+            "Content-Type": "application/json",
+            "Accept": "application/json",
+            "Authorization": f"ResultSink {auth_token}",
+        },
+        data=json.dumps({"test_results": [test_result]}),
+    )
+    res.raise_for_status()
+
+
+def add_artifacts_to_test_result(test_result, artifacts):
+    """
+    Args:
+        test_result (dict(str, str)): A dictionary containing the json test_results.
+
+        artifacts (list(list(str, str))): a list containing tuples of size 2 where
+            the first element of the tuple is the name of the artifact, and the
+            second is the file path to the artifact.
+    """
+    if not test_result.get("artifacts") and artifacts:
+        test_result["artifacts"] = {}
+
+    for name, path in artifacts:
+        test_result["artifacts"][name] = {"filePath": path}
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Uploads the test result to resultdb. The input is expected to be \
+        a json conforming to a TestResult proto message"
+    )
+
+    parser.add_argument(
+        "test_result",
+        action="store",
+        help="json string to upload to ResultDB",
+    )
+
+    parser.add_argument(
+        "--artifact",
+        dest="artifacts",
+        nargs=2,
+        action="append",
+        default=[],
+        help="artifact to upload as part of the test result, the first arg \
+        is the name of the artifact and the second is the path to the artifact, \
+        it can be repeated, \
+        e.g. --artifact foo path/to/foo --artifact bar path/to/bar",
+    )
+
+    args = parser.parse_args()
+
+    sink = None
+    if "LUCI_CONTEXT" in os.environ:
+        with open(os.environ["LUCI_CONTEXT"], encoding="utf-8") as f:
+            sink = json.load(f)["result_sink"]
+    if sink is None:
+        print("result_sink not defined in LUCI_CONTEXT")
+        return 1
+
+    if not args.test_result:
+        print("Empty test results: skipping")
+        return 0
+
+    url = str.format(
+        "http://{}/prpc/luci.resultsink.v1.Sink/{}",
+        sink["address"],
+        "ReportTestResults",
+    )
+
+    test_result = json.loads(args.test_result)
+    add_artifacts_to_test_result(test_result, args.artifacts)
+
+    print(f"Uploading test_result: {test_result}")
+    upload_results(test_result, url, sink["auth_token"])
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/recipe_modules/resultdb_reporter/tests/full.expected/basic.json b/recipe_modules/resultdb_reporter/tests/full.expected/basic.json
new file mode 100644
index 0000000..8482c25
--- /dev/null
+++ b/recipe_modules/resultdb_reporter/tests/full.expected/basic.json
@@ -0,0 +1,29 @@
+[
+  {
+    "cmd": [
+      "rdb",
+      "stream",
+      "--",
+      "vpython3",
+      "RECIPE_MODULE[flutter::resultdb_reporter]/resources/resultdb.py",
+      "{\"testId\": \"//test_suite/test_class/test_method\", \"expected\": false, \"summaryHtml\": \"summary\", \"status\": 2}"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "proj:realm"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/inv",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "upload to resultdb"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/resultdb_reporter/tests/full.expected/resultdb_not_enabled.json b/recipe_modules/resultdb_reporter/tests/full.expected/resultdb_not_enabled.json
new file mode 100644
index 0000000..6a23b22
--- /dev/null
+++ b/recipe_modules/resultdb_reporter/tests/full.expected/resultdb_not_enabled.json
@@ -0,0 +1,13 @@
+[
+  {
+    "cmd": [],
+    "name": "upload to resultdb",
+    "~followup_annotations": [
+      "@@@STEP_TEXT@ResultDB integration was not enabled for this build@@@",
+      "@@@STEP_EXCEPTION@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/resultdb_reporter/tests/full.py b/recipe_modules/resultdb_reporter/tests/full.py
new file mode 100644
index 0000000..11894d4
--- /dev/null
+++ b/recipe_modules/resultdb_reporter/tests/full.py
@@ -0,0 +1,36 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from PB.go.chromium.org.luci.lucictx import sections as sections_pb2
+from PB.go.chromium.org.luci.resultdb.proto.v1.test_result import TestStatus
+
+DEPS = [
+    "flutter/resultdb_reporter",
+    "recipe_engine/context",
+    "recipe_engine/step",
+]
+
+
+def RunSteps(api):
+  api.resultdb_reporter.report_result(
+      test_id='//test_suite/test_class/test_method',
+      summary='summary',
+      status=TestStatus.FAIL)
+
+
+def GenTests(api):
+    luci_context = api.context.luci_context(
+        realm=sections_pb2.Realm(name="proj:realm"),
+        resultdb=sections_pb2.ResultDB(
+            current_invocation=sections_pb2.ResultDBInvocation(
+                name="invocations/inv",
+                update_token="token",
+            ),
+            hostname="rdbhost",
+        ),
+    )
+
+    yield api.test("basic") + luci_context
+
+    yield api.test("resultdb_not_enabled")