Add reporter for ReportPortal

Related-Prod: PRODX-48948
Change-Id: Ib879a913513c8da5b9a6090798951e1396939bc2
diff --git a/rp_reporter/README.md b/rp_reporter/README.md
new file mode 100644
index 0000000..e6381a3
--- /dev/null
+++ b/rp_reporter/README.md
@@ -0,0 +1,49 @@
+## Install
+
+In case of existed git repository
+```shell
+pip install .
+
+```
+or 
+```shell
+pip install git+https//gerrit/path_in_gerrit TODO
+```
+
+## Use as a cli
+
+It will proceed to all child job and to find defined xml reports (defined in code)
+
+```shell
+rp-reporter report-job https://mos-ci.infra.mirantis.net/job/deploy_job/58/
+
+```
+
+The next command should upload testresults for all jobs from the defined view. Tool takes into account only the last built builds
+
+```shell
+rp-reporter  report-view "https://mos-ci.infra.mirantis.net/view/MOSK%2024.3%20CI/"  
+```
+
+## Use as a module
+
+```python
+import rp_reporter
+rp_reporter.upload_job("https://mos-ci.infra.mirantis.net/job/test-os-deploy-extended-core-ceph-local-non-dvr-ironic-mt-caracal-portgroups/6/", True)
+```
+
+## Configuration file
+By default, it's located in `~/.reportportal_config`
+You can change the default location by defining the `RP_CONFIG_FILE` environment variable 
+
+### Example of config file
+Config file is formatted as yaml 
+
+```yaml
+RP_APIKEY: <a lot of symbols>
+RP_ENDPOINT: http://172.19.124.20/
+RP_PROJECT: oscore
+```
+`RP_APIKEY` can be obtained in ReportPortal by url http://reportportal_url/ui/#userProfile/apiKeys 
+
+All variables in configfile can be overwriten by defining the according environment variable
\ No newline at end of file
diff --git a/rp_reporter/rp_reporter/__init__.py b/rp_reporter/rp_reporter/__init__.py
new file mode 100644
index 0000000..cd4189b
--- /dev/null
+++ b/rp_reporter/rp_reporter/__init__.py
@@ -0,0 +1 @@
+from .batch_reporter import upload_job
\ No newline at end of file
diff --git a/rp_reporter/rp_reporter/batch_reporter.py b/rp_reporter/rp_reporter/batch_reporter.py
new file mode 100755
index 0000000..dd67349
--- /dev/null
+++ b/rp_reporter/rp_reporter/batch_reporter.py
@@ -0,0 +1,166 @@
+import click
+import logging
+import ipdb
+import itertools
+import sys
+
+import jenkins_jinny.main as jj
+from copy import deepcopy
+
+from .report_from_xml import report_xml, timestamp
+from .report_from_xml import client as rp_client
+
+sys.path.append('../rp_reporter')
+import settings
+
+LOG = logging.getLogger(__name__)
+
+def upload_job(job:str, suite_per_job=False):
+    if isinstance(job, str):
+        job = jj.Build(job)
+    print(f"│˶˙ᵕ˙˶)꜆ I take {job}")
+    link = job.url
+
+    tags = {
+        "start_time": job.start_time.strftime("%Y-%m-%d-%H-%M-%S")
+    }
+    if context_name:=job.param.OPENSTACK_CONTEXT_NAME:
+        tags.update(
+            {
+                "openstack": context_name.split("/")[0],
+                "context": context_name.split("/")[-1],
+            }
+        )
+
+    if "mosk-24.3" in job.name:
+        tags.update(
+            {
+                "mosk_version": "mosk-24.3"
+            }
+        )
+    elif "mosk-25.1" in job.name:
+        tags.update(
+            {
+                "mosk_version": "mosk-25.1"
+            }
+        )
+    else:
+        tags.update(
+            {
+                "mosk_version": "master"
+            }
+        )
+
+    # deploy_job = job.get_child_jobs("deploy-openstack-k8s-env")[0]
+    # for file_location in deploy_job.get_artifacts("deployed.yaml"):
+    #     osdpl_content = yaml.safe_load_all(open(file_location))
+    #     yaml.safe_load_all
+    #     tags["rockoon"] = 
+
+    launch_id = None
+    if suite_per_job:
+        launch_id = rp_client.start_launch(name=f"{job.name} number: {job.number}",
+                            start_time=timestamp(),
+                            attributes=tags,
+                            description=f"Main job {job.url}"
+                            )
+
+    for child in itertools.chain([job], job.heirs):
+        print(f"⫍‍⌕‍⫎ tests in {child}")
+        test_tags = deepcopy(tags)
+        test_results_files = None
+        match child.name:
+            case "tempest-runner-k8s":
+                title = "Tempest"
+                test_results_files = [file_url
+                               for file_url in child.get_link_from_description()
+                               if "tempest_report.xml" in file_url]
+            case "stepler-runner-k8s":
+                title = "Stepler"
+                # report_path = [file_url
+                #                for file_url in child.get_link_from_description()
+                #                if "stepler_test_results.xml" in file_url ][0]
+                test_results_files = child.get_artifacts("stepler_test_results.xml")
+                if not test_results_files:
+                    LOG.error(f"Can't found 'stepler_test_results.xml' in "
+                              f"{child.url} artifacts")
+            case "oscore-si-tests-runner-k8s":
+                title = "SI tests"
+                test_tags["test"] = child.param.RUN_TESTS
+                test_results_files = [file_url
+                               for file_url in child.get_link_from_description()
+                               if "si_test_report.xml" in file_url ]
+                if not test_results_files:
+                    LOG.error(f"Can't found 'si_test_report.xml' in {child.url}")
+            case "oscore-functional-tests-runner":
+                title = "Rockoon Functional"
+                test_results_files = [file_url
+                               for file_url in child.get_link_from_description() 
+                               if "si_test_report.xml" in file_url]
+                if not test_results_files:
+                    LOG.error(f"Can't found 'si_test_report.xml' in {child.url}")
+            case "si-test-check-downtime-statistic":
+                title = "Downtime tests"
+                test_results_files = [
+                    f"{file_url}/artifacts/test_check_downtime_statistic_result.xml"
+                    for file_url in child.get_link_from_description()
+                ]
+                if not test_results_files:
+                    LOG.error(f"Can't found 'test_check_downtime_statistic_result.xml' in {child.url}")
+
+        if not test_results_files:
+            # We can iterate by child jobs which don't contain any reports
+            continue
+        report_path = test_results_files[0]
+        LOG.info("=== report_xml {kwargs}".format( kwargs = dict(report_path=report_path,
+            title = title,
+            attributes=test_tags,
+            link=link,
+            to_specific_launch=launch_id)))
+
+        print(f"(っ・-・)っ Uploading {report_path}")
+        report_xml(
+            report_path=report_path,
+            title = title,
+            attributes=test_tags,
+            link=link,
+            to_specific_launch=launch_id,
+        )
+
+        if suite_per_job:
+            rp_client.finish_launch(end_time=timestamp())
+            LOG.info(rp_client.get_launch_info())
+    if suite_per_job:
+        report_url = f"{settings.RP_ENDPOINT.strip('/')}/ui/#{settings.RP_PROJECT}/launches/all/{launch_id}"
+        print(f"maybe report is here {report_url}")
+    print(f" ʕノ•ᴥ•ʔノ Completed")
+
+
+def upload_view(view, pattern):
+    for job in jj.jobs_in_view(view, ""):
+        if not job.number:
+            continue
+        if pattern and not pattern in job.name:
+            continue
+        upload_job(job, suite_per_job=True)
+
+@click.group()
+def cli():
+    pass
+
+@cli.command()
+@click.argument("job_url")
+def report_job(job_url):
+    upload_job(job_url, suite_per_job=True)
+
+@cli.command()
+@click.option("--pattern", default=None, help="Upload only job with pattern")
+@click.argument('view_url')
+def report_view(view_url, pattern):
+    """
+    :param view_url: Url to the view
+    """
+    upload_view(view_url, pattern)
+
+if __name__ == '__main__':
+    cli()
diff --git a/rp_reporter/rp_reporter/report_from_xml.py b/rp_reporter/rp_reporter/report_from_xml.py
new file mode 100644
index 0000000..52fe9e6
--- /dev/null
+++ b/rp_reporter/rp_reporter/report_from_xml.py
@@ -0,0 +1,235 @@
+import os
+import click
+import xunitparser
+import logging
+import pathlib
+import datetime
+import re
+import wget
+import sys
+import time
+
+from reportportal_client import RPClient
+from reportportal_client.helpers import timestamp
+
+sys.path.append('../rp_reporter')
+import settings
+
+logging.basicConfig(level=logging.INFO,
+                    format='%(asctime)s %(levelname)s - %(filename)s:%(lineno)d (%(funcName)s) - %(message)s',
+                    filename='report.log',
+                    filemode='w')
+LOG = logging.getLogger(__name__)
+LOG.setLevel(logging.WARNING)
+scheduled = []
+
+def schedule_finishing(item):
+    scheduled.append(item)
+
+def finish_all():
+    while scheduled:
+        finishing_item = scheduled.pop()
+        client.finish_test_item(item_id=finishing_item,
+                                end_time=timestamp()
+                                )
+
+client = RPClient(endpoint=settings.RP_ENDPOINT,
+              project=settings.RP_PROJECT,
+              api_key=settings.RP_APIKEY,
+              is_skipped_an_issue=False,
+              truncate_attributes=False
+              )
+
+uuid_by_subfolder = dict()
+def create_subfolders(list_of_subfolders:list, root_folder=None) -> str:
+    parent_folder = root_folder
+    for number, subfolder in enumerate(list_of_subfolders):
+        subfolder_fullpath = list_of_subfolders[:number]
+        subfolder_fullpath.append(subfolder)
+        fullpath = ".".join(subfolder_fullpath)
+        if fullpath not in uuid_by_subfolder.keys():
+            created_folder = client.start_test_item(
+                name=subfolder,
+                start_time=timestamp(),
+                item_type="suite",
+                parent_item_id=parent_folder
+            )
+            LOG.debug(f"Started suite: uuid={created_folder} for suite {subfolder} in {fullpath} ")
+            schedule_finishing(created_folder)
+            uuid_by_subfolder[fullpath] = created_folder
+        folder_uuid = uuid_by_subfolder.get(fullpath)
+        parent_folder = folder_uuid
+    return parent_folder
+
+def report_xml(report_path, title,
+               attributes, link, to_specific_launch=None):
+    ts: xunitparser.TestSuite
+    tr: xunitparser.TestResult
+    tc: xunitparser.TestCase
+    # FIXME rewrite it to not use globals
+    global uuid_by_subfolder
+    uuid_by_subfolder = dict()
+    global scheduled
+    scheduled = list()
+    _report_path = str(report_path)
+    if report_path.startswith("http"):
+        report_path = wget.download(report_path, f"/tmp/")
+    if pathlib.Path(report_path).exists():
+        report_file = report_path
+    else:
+        raise FileNotFoundError(report_path)
+    ts, tr = xunitparser.parse(open(report_file))
+    # ipdb.set_trace()
+
+    # xunitparser can't provide start_time from xml
+    # so we can use only now()
+    # if tr.timestamp:
+    #     start_time = datetime.datetime.fromisoformat(tr.timestamp)
+    # ipdb.set_trace()
+    # if tr.time
+    start_time = int(os.stat(report_file).st_birthtime * 1000)
+    attributes.update(ts.properties)
+
+    last_subfolder = None
+    if to_specific_launch:
+        test_suite = client.start_test_item(
+            name=title,
+            start_time=str(start_time),
+            attributes=attributes,
+            item_type="suite",
+            description=f"{link} \n uploaded from report {_report_path} ",
+        )
+        schedule_finishing(item=test_suite)
+        root_folder = test_suite
+    else:
+        client.start_launch(name=title,
+                            start_time=str(start_time),
+                            attributes=attributes,
+                            description=f"{link} \n uploaded from report {_report_path} "
+                            )
+        root_folder = None
+
+    LOG.info(f"Sending to RP")
+
+    ts_with_progress_bar = click.progressbar(ts, 
+                                             label='Sending to RP',
+                                            #  item_show_func=lambda a: a.methodname if a is not None,
+                                             length=tr.testsRun,
+                                             show_percent=True,
+                                             bar_template='[%(bar)s] %(info)s %(label)s'
+                                             )
+    with ts_with_progress_bar: 
+        started_at = time.time()
+
+        for tc in ts_with_progress_bar:
+            if tc.classname:
+                last_subfolder = create_subfolders(
+                    list_of_subfolders=tc.classname.split("."),
+                    root_folder=root_folder
+                )
+            elif "setup" in tc.methodname.lower() or "teardown" in tc.methodname.lower():
+                # setup and teardown don't have classname but have path in their name like
+                # in tempest:
+                # setUpClass (tempest.api.compute.admin.test_create_server.WindowsServers11Test)
+                found_text: list = re.findall(r"\([\w.]+\)", tc.methodname)
+                if found_text:
+                    found_text: str = found_text[-1]
+                if found_text:
+                    found_text: str = found_text.strip("()")
+                last_subfolder = create_subfolders(
+                    list_of_subfolders=found_text.split("."),
+                    root_folder=root_folder)
+                # name = f"{tc.classname}.{tc.methodname}"
+            # else:
+            name = tc.methodname
+            elapsed = time.time() - started_at
+            ts_with_progress_bar.label = f"{elapsed:.2f}s {name}".replace("\n", " ")
+
+            # It's a bad way to detect setup\teardown because every testing
+            # framework has his own way to log setup\teardown
+            # Also test name can contain setup
+            # if "setup" in tc.methodname.lower():
+            #     item_type="setup"
+            # elif "teardown" in tc.methodname.lower():
+            #     item_type="teardown"
+            # else:
+            #     item_type = "STEP"
+            item_type = "STEP"
+
+            # ipdb.set_trace()
+            test_started_at = timestamp()
+            item_id = client.start_test_item(
+                name=name,
+                start_time=test_started_at,
+                item_type=item_type,
+                description=f"{tc.classname}.{tc.methodname}",
+                parent_item_id=last_subfolder
+            )
+            if not item_id:
+                raise Exception(f"Failed to start test {name}")
+            match tc.result:
+                case "success":
+                    status = "PASSED"
+                case "skipped":
+                    status = "SKIPPED"
+                case "failure":
+                    status = "FAILED"
+                    met_failed = True
+                case "error":
+                    status = "INTERRUPTED"
+                    met_failed = True
+                case _:
+                    raise BaseException(f"Unknown {tc.result=} in xml")
+
+            # LOG.debug(f"Logging {tc.trace}")
+            # ipdb.set_trace()
+            if tc.message:
+                client.log(time=timestamp(),
+                        message=tc.message,
+                        item_id=item_id
+                        )
+            if tc.trace:
+                client.log(time=timestamp(),
+                        message=tc.trace,
+                        item_id=item_id
+                        )
+            
+            # timestamp() 1739905243451 in milliseconds
+            # tc.time datetime.timedelta(microseconds=259000)
+            end_time_with_duration = datetime.datetime.fromtimestamp(
+                int(test_started_at) / 1000) + tc.time
+            end_time_in_milliseconds = int(
+                end_time_with_duration.timestamp() * 1000)
+
+            client.finish_test_item(item_id=item_id,
+                                    end_time=str(end_time_in_milliseconds),
+                                    status=status,
+                                    )
+            finish_all()
+
+    if not to_specific_launch:
+        client.finish_launch(end_time=timestamp())
+    LOG.info(client.get_launch_info())
+
+if __name__ == "__main__":
+
+    report_path = 'https://artifactory.mcp.mirantis.net/artifactory/oscore-local/jenkins-job-artifacts/oscore-si-tests-runner-k8s/40162/si_test_report.xml'
+    title = "Rockoon functional"
+    attributes = {
+        # "openstack_version": "caracal",
+        "mosk_version": "mosk-24.3.3"
+    }
+    link = ""
+    
+    
+    report_xml(
+        report_path=report_path,
+        title = title,
+        attributes=attributes,
+        link=link,
+        # to_specific_launch="432ce97b-2727-4e4c-8303-9f1d966a184e"
+        )
+
+
+
+    # client.terminate()
diff --git a/rp_reporter/settings.py b/rp_reporter/settings.py
new file mode 100644
index 0000000..13f6a6c
--- /dev/null
+++ b/rp_reporter/settings.py
@@ -0,0 +1,35 @@
+from os import environ
+import yaml
+from pathlib import Path
+import logging
+
+LOG = logging.getLogger(__name__)
+
+RP_CONFIG_FILE = environ.get("RP_CONFIG_FILE") or (Path.home() / ".reportportal_config")
+
+def from_conf(key_name):
+    if not Path(RP_CONFIG_FILE).exists():
+        LOG.warning(f"Can't get {key_name} because config file "
+                    f"not found: {RP_CONFIG_FILE}")
+        return None
+    with open(RP_CONFIG_FILE) as f:
+        yaml_config = yaml.safe_load(f)
+        value = yaml_config.get(key_name)
+        if value is None:
+            LOG.warning(f"Can't get {key_name} because it's absent in {RP_CONFIG_FILE}")
+        return value
+
+def call_error(key_name):
+    raise Exception(f"{key_name} should be defined in {RP_CONFIG_FILE} or "
+                    f"by environment variable")
+
+RP_APIKEY = environ.get('RP_APIKEY') or from_conf('RP_APIKEY') or call_error("RP_APIKEY")
+RP_ENDPOINT = environ.get('RP_ENDPOINT') or from_conf('RP_ENDPOINT') or call_error("RP_ENDPOINT")
+RP_PROJECT = environ.get('RP_PROJECT') or from_conf('RP_PROJECT') or call_error("RP_PROJECT")
+
+RP_LOG_FILE = environ.get('RP_LOG_FILE') or from_conf('RP_LOG_FILE') or (Path.cwd() / "report.log")
+
+if __name__ == "__main__":
+    LOG.info(f"RP_APIKEY: {RP_APIKEY}")
+    LOG.info(f"RP_ENDPOINT: {RP_ENDPOINT}")
+    LOG.info(f"RP_PROJECT: {RP_PROJECT}")
\ No newline at end of file
diff --git a/rp_reporter/setup.py b/rp_reporter/setup.py
new file mode 100644
index 0000000..c94d350
--- /dev/null
+++ b/rp_reporter/setup.py
@@ -0,0 +1,25 @@
+from setuptools import setup, find_packages
+
+setup(
+    name="rp-reporter",
+    version="0.1.0",
+    author="Anna Arhipova",
+    author_email="harhipova@mirantis.com",
+    description="Reporting test results to the Report Portal",
+    long_description=open("README.md").read(),
+    long_description_content_type="text/markdown",
+    packages=find_packages(),
+    install_requires=[
+        "xunitparser",  # List dependencies here
+        "reportportal-client",
+        "PyYAML",
+        "wget",
+        "jenkins-jinny @ git+https://github.com/annkapul/jenkins-jinny.git"
+    ],
+    python_requires=">=3.9",
+    entry_points={
+        "console_scripts": [
+            "rp-reporter=rp_reporter.batch_reporter:cli",
+        ],
+    },
+)
\ No newline at end of file