chromium/third_party/blink/tools/blinkpy/web_tests/vts_notifier.py

# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Sends notifications for expired Virtual Test Suites in
web_tests/VirtualTestSuites
"""

import argparse
import datetime
import logging
from typing import Optional

from blinkpy.common.path_finder import PathFinder
from blinkpy.common.system.log_utils import configure_logging
from blinkpy.w3c.buganizer import (
    BuganizerClient,
    BuganizerError,
    BuganizerIssue,
    Priority,
)
from blinkpy.w3c.directory_owners_extractor import DirectoryOwnersExtractor
from blinkpy.web_tests.port.base import Port, VirtualTestSuite

_log = logging.getLogger(__name__)

# component ID for Default DIR_METADTA of wpt_internal/* and blink/*
BLINK_COMPONENT_ID = '1456407'
# component ID for Default DIR_METADTA of external/wpt/*
BLINK_INFRA_ECOSYSTEM_COMPONENT_ID = '1456176'
DEFAULT_COMPONENT_IDS = [
    BLINK_INFRA_ECOSYSTEM_COMPONENT_ID, BLINK_COMPONENT_ID
]

BUGAINZER_DESCRIPTION_TEMPLATE = '''\
This virtual test suite '{prefix}' expired on {expires}.

If the virtual test suite is no longer needed, please remove the entry from
//third_party/blink/web_tests/VirtualTestSuites.
Otherwise, update the expiration date.

Note: Test suite owners are automatically added to CC.
'''


class VTSNotifier:

    def __init__(self,
                 host,
                 buganizer_client: Optional[BuganizerClient] = None):
        self.host = host
        self.port: Port = host.port_factory.get()
        self.buganizer_client = buganizer_client or BuganizerClient()
        self.path_finder = PathFinder(self.host.filesystem)
        self.owners_extractor = DirectoryOwnersExtractor(host)
        self._dry_run = False

    def run(self, argv=None):
        options = self.parse_args(argv)
        configure_logging(
            logging_level=logging.DEBUG if options.verbose else logging.INFO,
            include_time=True)
        self._dry_run = options.dry_run
        virtual_test_suites = self.port.virtual_test_suites()
        _log.info(
            f'Processing {len(virtual_test_suites)} virtual test suites.')
        expired_virtual_test_suites = list(
            filter(self.check_expired_vts, virtual_test_suites))
        _log.info(f'Processing {len(expired_virtual_test_suites)} '
                  'expired virtual test suites.')
        for expired_virtual_test_suite in expired_virtual_test_suites:
            bug = self.create_draft_bug(expired_virtual_test_suite)
            self.process_draft_bug(bug)

    def parse_args(self, argv):
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument(
            '-v',
            '--verbose',
            action='store_true',
            help='Log extra details that may be helpful when debugging.')
        parser.add_argument(
            '--dry-run',
            action='store_true',
            help='See what would be done without actually creating bug.')
        return parser.parse_args(argv)

    def process_draft_bug(self, bug):
        """Issue new bug if bug with the same title does not already exist"""
        try:
            # Checks if a bug with the same title exists on Buganizer
            existing_bugs = self.buganizer_client.GetIssueList(
                f'title:"{bug.title}"')
            if existing_bugs:
                _log.info(f'Bug already created: {existing_bugs[0].link}')
            elif self._dry_run:
                _log.info('[dry_run] would have created the following bug:\n'
                          f'{bug}')
            else:
                bug = self.buganizer_client.NewIssue(bug)
                _log.info(f'Filed bug: {bug.link}')
        except BuganizerError as e:
            _log.exception(f'Failed to process bug: {e}')

    def create_draft_bug(self, vts: VirtualTestSuite) -> BuganizerIssue:
        """Construct a bug for taking action on an expired VirtualTestSuite."""
        _log.debug(f'Expired VTS found: {vts.prefix=}.')
        title = ('[VT Expire Notice] Virtual test suite '
                 f'{vts.prefix} expired on {vts.expires}, '
                 'action required')
        description = BUGAINZER_DESCRIPTION_TEMPLATE.format(
            prefix=vts.prefix, expires=vts.expires)
        component_id = self.resolve_component_id(vts)
        bug = BuganizerIssue(title=title,
                             description=description,
                             component_id=component_id,
                             cc=vts.owners or [],
                             priority=Priority.P1)
        return bug

    def resolve_component_id(self, vts: VirtualTestSuite) -> str:
        """Find a component ID to file a bug to for an expired virtual suite.

        `VTSNotifier` will try the following in order to find a relevant Buganizer
        component:
        1. The component in `virtual/x/DIR_METADATA`.
        2. The component in the `DIR_METADATA` that the suite's bases roll up to
           that is not `Blink>Infra>Ecosystem` or `Blink`
        3. The component for `Blink>Infra>Ecosystem`.
        """
        vts_dir = self.host.filesystem.join(self.port.web_tests_dir(),
                                            vts.full_prefix)
        _log.info(f'Resolving component ID for {vts.full_prefix}.')
        component_id = self.read_component_from_metadata(vts_dir)
        if component_id and component_id not in DEFAULT_COMPONENT_IDS:
            return component_id
        return self.find_first_base_component_id(vts.bases)

    def read_component_from_metadata(self, dir_path) -> str:
        dir_metadata = self.owners_extractor.read_dir_metadata(dir_path)
        if dir_metadata and dir_metadata.buganizer_public_component:
            return dir_metadata.buganizer_public_component
        return ''

    def find_first_base_component_id(self, bases) -> str:
        """Find a component ID from the bases of a virtual test suite

        This method iterates through the bases of a virtual test suite and
        returns the component ID from the `DIR_METADATA` file that is not one
        of the default component IDs.
        """
        for base in bases:
            test_path = self.path_finder.path_from_web_tests(base)
            test_directory = (test_path
                              if self.host.filesystem.isdir(test_path) else
                              self.host.filesystem.dirname(test_path))
            component_id = self.read_component_from_metadata(test_directory)
            if component_id and component_id not in DEFAULT_COMPONENT_IDS:
                _log.info(f'Using base, {base}, to resolve component ID to '
                          f'{component_id}.')
                return component_id
        _log.info('No DIR_METADATA found. Using Blink component.')
        return BLINK_COMPONENT_ID

    def check_expired_vts(self, vts: VirtualTestSuite):
        """Check if the virtual test suite is expired

        Returns:
            True if the VTS has expired, False otherwise.
        """
        if not vts.expires or vts.expires == 'never':
            return False
        for date_format in ['%b %d, %Y', '%B %d, %Y']:
            try:
                expiration_date = datetime.datetime.strptime(
                    vts.expires, date_format)
                if datetime.datetime.today() >= expiration_date:
                    return True
            except ValueError:
                continue
        return False