diff --git a/.taskcluster.yml b/.taskcluster.yml index f2b4ff434..6f66e0f02 100644 --- a/.taskcluster.yml +++ b/.taskcluster.yml @@ -427,8 +427,9 @@ tasks: - "git clone --quiet ${repository} && cd code-coverage && git checkout ${head_rev} && - sed -i -e 's/CHANNEL/${channel}/g' -e 's/REVISION/${head_rev}/g' bot/taskcluster-hook.json && - taskboot --target . build-hook bot/taskcluster-hook.json project-relman code-coverage-${channel}" + sed -i -e 's/CHANNEL/${channel}/g' -e 's/REVISION/${head_rev}/g' bot/taskcluster-hook-*.json && + taskboot --target . build-hook bot/taskcluster-hook-repo.json project-relman code-coverage-repo-${channel} && + taskboot --target . build-hook bot/taskcluster-hook-cron.json project-relman code-coverage-cron-${channel}" metadata: name: "Code Coverage Bot hook update (${channel})" description: Update Taskcluster hook triggering the code-coverage tasks diff --git a/bot/code_coverage_bot/artifacts.py b/bot/code_coverage_bot/artifacts.py index 3b054b733..dce10828a 100644 --- a/bot/code_coverage_bot/artifacts.py +++ b/bot/code_coverage_bot/artifacts.py @@ -140,6 +140,7 @@ def download_all(self): [ taskcluster.get_task_details(build_task_id)["taskGroupId"] for build_task_id in self.task_ids.values() + if build_task_id is not None ] ) test_tasks = [ diff --git a/bot/code_coverage_bot/cli.py b/bot/code_coverage_bot/cli.py index b6601233b..643131bd1 100644 --- a/bot/code_coverage_bot/cli.py +++ b/bot/code_coverage_bot/cli.py @@ -7,19 +7,20 @@ import os from code_coverage_bot import config -from code_coverage_bot.codecov import CodeCov from code_coverage_bot.secrets import secrets from code_coverage_bot.taskcluster import taskcluster_config from code_coverage_tools.log import init_logger -def parse_cli(): +def setup_cli(ask_repository=True, ask_revision=True): """ - Setup CLI options parser + Setup CLI options parser and taskcluster bootstrap """ parser = argparse.ArgumentParser(description="Mozilla Code Coverage Bot") - parser.add_argument("--repository", default=os.environ.get("REPOSITORY")) - parser.add_argument("--revision", default=os.environ.get("REVISION")) + if ask_repository: + parser.add_argument("--repository", default=os.environ.get("REPOSITORY")) + if ask_revision: + parser.add_argument("--revision", default=os.environ.get("REVISION")) parser.add_argument( "--cache-root", required=True, help="Cache root, used to pull changesets" ) @@ -35,11 +36,7 @@ def parse_cli(): ) parser.add_argument("--taskcluster-client-id", help="Taskcluster Client ID") parser.add_argument("--taskcluster-access-token", help="Taskcluster Access token") - return parser.parse_args() - - -def main(): - args = parse_cli() + args = parser.parse_args() # Auth on Taskcluster taskcluster_config.auth(args.taskcluster_client_id, args.taskcluster_access_token) @@ -55,9 +52,4 @@ def main(): sentry_dsn=secrets.get("SENTRY_DSN"), ) - c = CodeCov(args.repository, args.revision, args.task_name_filter, args.cache_root) - c.go() - - -if __name__ == "__main__": - main() + return args diff --git a/bot/code_coverage_bot/codecov.py b/bot/code_coverage_bot/codecov.py deleted file mode 100644 index 2c1c3c617..000000000 --- a/bot/code_coverage_bot/codecov.py +++ /dev/null @@ -1,303 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -import os -import tempfile -import zipfile -from datetime import datetime -from datetime import timedelta - -import hglib -import structlog - -from code_coverage_bot import chunk_mapping -from code_coverage_bot import grcov -from code_coverage_bot import hgmo -from code_coverage_bot import taskcluster -from code_coverage_bot import uploader -from code_coverage_bot.artifacts import ArtifactsHandler -from code_coverage_bot.notifier import notify_email -from code_coverage_bot.phabricator import PhabricatorUploader -from code_coverage_bot.phabricator import parse_revision_id -from code_coverage_bot.secrets import secrets -from code_coverage_bot.taskcluster import taskcluster_config -from code_coverage_bot.utils import ThreadPoolExecutorResult -from code_coverage_bot.zero_coverage import ZeroCov - -logger = structlog.get_logger(__name__) - - -HG_BASE = "https://hg.mozilla.org/" -MOZILLA_CENTRAL_REPOSITORY = "{}mozilla-central".format(HG_BASE) -TRY_REPOSITORY = "{}try".format(HG_BASE) - - -class CodeCov(object): - def __init__(self, repository, revision, task_name_filter, cache_root): - # List of test-suite, sorted alphabetically. - # This way, the index of a suite in the array should be stable enough. - self.suites = ["web-platform-tests"] - - self.cache_root = cache_root - - temp_dir = tempfile.mkdtemp() - self.artifacts_dir = os.path.join(temp_dir, "ccov-artifacts") - self.reports_dir = os.path.join(temp_dir, "ccov-reports") - - self.index_service = taskcluster_config.get_service("index") - - if revision is None: - # Retrieve latest ingested revision - self.repository = MOZILLA_CENTRAL_REPOSITORY - try: - self.revision = uploader.gcp_latest("mozilla-central")[0]["revision"] - except Exception as e: - logger.warn( - "Failed to retrieve the latest reports ingested: {}".format(e) - ) - raise - self.from_pulse = False - else: - self.repository = repository - self.revision = revision - self.from_pulse = True - - self.branch = self.repository[len(HG_BASE) :] - - assert os.path.isdir(cache_root), "Cache root {} is not a dir.".format( - cache_root - ) - self.repo_dir = os.path.join(cache_root, self.branch) - - logger.info("Mercurial revision", revision=self.revision) - - task_ids = {} - for platform in ["linux", "windows", "android-test", "android-emulator"]: - task = taskcluster.get_task(self.branch, self.revision, platform) - - # On try, developers might have requested to run only one platform, and we trust them. - # On mozilla-central, we want to assert that every platform was run (except for android platforms - # as they are unstable). - if task is not None: - task_ids[platform] = task - elif ( - self.repository == MOZILLA_CENTRAL_REPOSITORY - and not platform.startswith("android") - ): - raise Exception("Code coverage build failed and was not indexed.") - - self.artifactsHandler = ArtifactsHandler( - task_ids, self.artifacts_dir, task_name_filter - ) - - def clone_repository(self, repository, revision): - cmd = hglib.util.cmdbuilder( - "robustcheckout", - repository, - self.repo_dir, - purge=True, - sharebase="hg-shared", - upstream="https://hg.mozilla.org/mozilla-unified", - revision=revision, - networkattempts=7, - ) - - cmd.insert(0, hglib.HGPATH) - - proc = hglib.util.popen(cmd) - out, err = proc.communicate() - if proc.returncode: - raise hglib.error.CommandError(cmd, proc.returncode, out, err) - - logger.info("{} cloned".format(repository)) - - def retrieve_source_and_artifacts(self): - with ThreadPoolExecutorResult(max_workers=2) as executor: - # Thread 1 - Download coverage artifacts. - executor.submit(self.artifactsHandler.download_all) - - # Thread 2 - Clone repository. - executor.submit(self.clone_repository, self.repository, self.revision) - - def build_reports(self, only=None): - """ - Build all the possible covdir reports using current artifacts - """ - os.makedirs(self.reports_dir, exist_ok=True) - - reports = {} - for ( - (platform, suite), - artifacts, - ) in self.artifactsHandler.get_combinations().items(): - - if only is not None and (platform, suite) not in only: - continue - - # Generate covdir report for that suite & platform - logger.info( - "Building covdir suite report", - suite=suite, - platform=platform, - artifacts=len(artifacts), - ) - output = grcov.report( - artifacts, source_dir=self.repo_dir, out_format="covdir" - ) - - # Write output on FS - path = os.path.join(self.reports_dir, f"{platform}.{suite}.json") - with open(path, "wb") as f: - f.write(output) - - reports[(platform, suite)] = path - - return reports - - def upload_reports(self, reports): - """ - Upload all provided covdir reports on GCP - """ - for (platform, suite), path in reports.items(): - report = open(path, "rb").read() - uploader.gcp( - self.branch, self.revision, report, suite=suite, platform=platform - ) - - def check_javascript_files(self): - """ - Check that all JavaScript files present in the coverage artifacts actually exist. - If they don't, there might be a bug in the LCOV rewriter. - """ - for artifact in self.artifactsHandler.get(): - if "jsvm" not in artifact: - continue - - with zipfile.ZipFile(artifact, "r") as zf: - for file_name in zf.namelist(): - with zf.open(file_name, "r") as fl: - source_files = [ - line[3:].decode("utf-8").rstrip() - for line in fl - if line.startswith(b"SF:") - ] - missing_files = [ - f - for f in source_files - if not os.path.exists(os.path.join(self.repo_dir, f)) - ] - if len(missing_files) != 0: - logger.warn( - f"{missing_files} are present in coverage reports, but missing from the repository" - ) - - # This function is executed when the bot is triggered at the end of a mozilla-central build. - def go_from_trigger_mozilla_central(self): - # Check the covdir report does not already exists - if uploader.gcp_covdir_exists(self.branch, self.revision, "all", "all"): - logger.warn("Full covdir report already on GCP") - return - - self.retrieve_source_and_artifacts() - - self.check_javascript_files() - - reports = self.build_reports() - logger.info("Built all covdir reports", nb=len(reports)) - - # Retrieve the full report - full_path = reports.get(("all", "all")) - assert full_path is not None, "Missing full report (all:all)" - report = json.load(open(full_path)) - - paths = uploader.covdir_paths(report) - expected_extensions = [".js", ".cpp"] - for extension in expected_extensions: - assert any( - path.endswith(extension) for path in paths - ), "No {} file in the generated report".format(extension) - - self.upload_reports(reports) - logger.info("Uploaded all covdir reports", nb=len(reports)) - - # Get pushlog and ask the backend to generate the coverage by changeset - # data, which will be cached. - with hgmo.HGMO(self.repo_dir) as hgmo_server: - changesets = hgmo_server.get_automation_relevance_changesets(self.revision) - - logger.info("Upload changeset coverage data to Phabricator") - phabricatorUploader = PhabricatorUploader(self.repo_dir, self.revision) - changesets_coverage = phabricatorUploader.upload(report, changesets) - - notify_email(self.revision, changesets, changesets_coverage) - - # This function is executed when the bot is triggered at the end of a try build. - def go_from_trigger_try(self): - phabricatorUploader = PhabricatorUploader(self.repo_dir, self.revision) - - with hgmo.HGMO(server_address=TRY_REPOSITORY) as hgmo_server: - changesets = hgmo_server.get_automation_relevance_changesets(self.revision) - - if not any( - parse_revision_id(changeset["desc"]) is not None for changeset in changesets - ): - logger.info( - "None of the commits in the try push are linked to a Phabricator revision" - ) - return - - self.retrieve_source_and_artifacts() - - reports = self.build_reports(only=[("all", "all")]) - full_path = reports.get(("all", "all")) - assert full_path is not None, "Missing full report (all:all)" - report = json.load(open(full_path)) - - logger.info("Upload changeset coverage data to Phabricator") - phabricatorUploader.upload(report, changesets) - - # This function is executed when the bot is triggered via cron. - def go_from_cron(self): - self.retrieve_source_and_artifacts() - - logger.info("Generating zero coverage reports") - zc = ZeroCov(self.repo_dir) - zc.generate(self.artifactsHandler.get(), self.revision) - - logger.info("Generating chunk mapping") - chunk_mapping.generate(self.repo_dir, self.revision, self.artifactsHandler) - - # Index the task in the TaskCluster index at the given revision and as "latest". - # Given that all tasks have the same rank, the latest task that finishes will - # overwrite the "latest" entry. - namespaces = [ - "project.releng.services.project.{}.code_coverage_bot.{}".format( - secrets[secrets.APP_CHANNEL], self.revision - ), - "project.releng.services.project.{}.code_coverage_bot.latest".format( - secrets[secrets.APP_CHANNEL] - ), - ] - - for namespace in namespaces: - self.index_service.insertTask( - namespace, - { - "taskId": os.environ["TASK_ID"], - "rank": 0, - "data": {}, - "expires": (datetime.utcnow() + timedelta(180)).strftime( - "%Y-%m-%dT%H:%M:%S.%fZ" - ), - }, - ) - - def go(self): - if not self.from_pulse: - self.go_from_cron() - elif self.repository == TRY_REPOSITORY: - self.go_from_trigger_try() - elif self.repository == MOZILLA_CENTRAL_REPOSITORY: - self.go_from_trigger_mozilla_central() - else: - assert False, "We shouldn't be here!" diff --git a/bot/code_coverage_bot/config.py b/bot/code_coverage_bot/config.py index 1b08a10b1..05a67bab9 100644 --- a/bot/code_coverage_bot/config.py +++ b/bot/code_coverage_bot/config.py @@ -4,3 +4,6 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. PROJECT_NAME = "code-coverage-bot" +HG_BASE = "https://hg.mozilla.org/" +MOZILLA_CENTRAL_REPOSITORY = "{}mozilla-central".format(HG_BASE) +TRY_REPOSITORY = "{}try".format(HG_BASE) diff --git a/bot/code_coverage_bot/hgmo.py b/bot/code_coverage_bot/hgmo.py index d08052051..32a41c1b2 100644 --- a/bot/code_coverage_bot/hgmo.py +++ b/bot/code_coverage_bot/hgmo.py @@ -22,6 +22,9 @@ def __init__(self, repo_dir=None, server_address=None): else: self.server_address = HGMO.SERVER_ADDRESS self.repo_dir = repo_dir + logger.info( + "Configured HGMO server", address=self.server_address, dir=self.repo_dir + ) self.pid_file = os.path.join(os.getcwd(), HGMO.PID_FILE) def __get_pid(self): diff --git a/bot/code_coverage_bot/hooks/__init__.py b/bot/code_coverage_bot/hooks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/code_coverage_bot/hooks/base.py b/bot/code_coverage_bot/hooks/base.py new file mode 100644 index 000000000..897339d11 --- /dev/null +++ b/bot/code_coverage_bot/hooks/base.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import tempfile + +import hglib +import structlog + +from code_coverage_bot import config +from code_coverage_bot import grcov +from code_coverage_bot import taskcluster +from code_coverage_bot.artifacts import ArtifactsHandler +from code_coverage_bot.utils import ThreadPoolExecutorResult + +logger = structlog.get_logger(__name__) + + +PLATFORMS = ["linux", "windows", "android-test", "android-emulator"] + + +class Hook(object): + def __init__( + self, repository, revision, task_name_filter, cache_root, required_platforms=[] + ): + temp_dir = tempfile.mkdtemp() + self.artifacts_dir = os.path.join(temp_dir, "ccov-artifacts") + self.reports_dir = os.path.join(temp_dir, "ccov-reports") + + self.repository = repository + self.revision = revision + assert ( + self.revision is not None and self.repository is not None + ), "Missing repo/revision" + logger.info( + "Mercurial setup", repository=self.repository, revision=self.revision + ) + + assert os.path.isdir(cache_root), f"Cache root {cache_root} is not a dir." + self.repo_dir = os.path.join(cache_root, self.branch) + + # Load current coverage task for all platforms + task_ids = { + platform: taskcluster.get_task(self.branch, self.revision, platform) + for platform in PLATFORMS + } + + # Check the required platforms are present + for platform in required_platforms: + if not task_ids[platform]: + raise Exception( + f"Code coverage build on {platform} failed and was not indexed." + ) + + self.artifactsHandler = ArtifactsHandler( + task_ids, self.artifacts_dir, task_name_filter + ) + + @property + def branch(self): + return self.repository[len(config.HG_BASE) :] + + def clone_repository(self): + cmd = hglib.util.cmdbuilder( + "robustcheckout", + self.repository, + self.repo_dir, + purge=True, + sharebase="hg-shared", + upstream="https://hg.mozilla.org/mozilla-unified", + revision=self.revision, + networkattempts=7, + ) + + cmd.insert(0, hglib.HGPATH) + + proc = hglib.util.popen(cmd) + out, err = proc.communicate() + if proc.returncode: + raise hglib.error.CommandError(cmd, proc.returncode, out, err) + + logger.info("{} cloned".format(self.repository)) + + def retrieve_source_and_artifacts(self): + with ThreadPoolExecutorResult(max_workers=2) as executor: + # Thread 1 - Download coverage artifacts. + executor.submit(self.artifactsHandler.download_all) + + # Thread 2 - Clone repository. + executor.submit(self.clone_repository) + + def build_reports(self, only=None): + """ + Build all the possible covdir reports using current artifacts + """ + os.makedirs(self.reports_dir, exist_ok=True) + + reports = {} + for ( + (platform, suite), + artifacts, + ) in self.artifactsHandler.get_combinations().items(): + + if only is not None and (platform, suite) not in only: + continue + + # Generate covdir report for that suite & platform + logger.info( + "Building covdir suite report", + suite=suite, + platform=platform, + artifacts=len(artifacts), + ) + output = grcov.report( + artifacts, source_dir=self.repo_dir, out_format="covdir" + ) + + # Write output on FS + path = os.path.join(self.reports_dir, f"{platform}.{suite}.json") + with open(path, "wb") as f: + f.write(output) + + reports[(platform, suite)] = path + + return reports diff --git a/bot/code_coverage_bot/hooks/cron.py b/bot/code_coverage_bot/hooks/cron.py new file mode 100644 index 000000000..4c2ea3b05 --- /dev/null +++ b/bot/code_coverage_bot/hooks/cron.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +from datetime import datetime +from datetime import timedelta + +import structlog + +from code_coverage_bot import chunk_mapping +from code_coverage_bot import config +from code_coverage_bot import uploader +from code_coverage_bot.cli import setup_cli +from code_coverage_bot.hooks.base import Hook +from code_coverage_bot.secrets import secrets +from code_coverage_bot.taskcluster import taskcluster_config +from code_coverage_bot.zero_coverage import ZeroCov + +logger = structlog.get_logger(__name__) + + +class CronHook(Hook): + """ + This function is executed when the bot is triggered via cron. + """ + + def __init__(self, *args, **kwargs): + + # Retrieve latest ingested revision + try: + revision = uploader.gcp_latest("mozilla-central")[0]["revision"] + except Exception as e: + logger.warn("Failed to retrieve the latest reports ingested: {}".format(e)) + raise + + super().__init__(config.MOZILLA_CENTRAL_REPOSITORY, revision, *args, **kwargs) + + def run(self): + self.retrieve_source_and_artifacts() + + logger.info("Generating zero coverage reports") + zc = ZeroCov(self.repo_dir) + zc.generate(self.artifactsHandler.get(), self.revision) + + logger.info("Generating chunk mapping") + chunk_mapping.generate(self.repo_dir, self.revision, self.artifactsHandler) + + # Index the task in the TaskCluster index at the given revision and as "latest". + # Given that all tasks have the same rank, the latest task that finishes will + # overwrite the "latest" entry. + namespaces = [ + "project.releng.services.project.{}.code_coverage_bot.{}".format( + secrets[secrets.APP_CHANNEL], self.revision + ), + "project.releng.services.project.{}.code_coverage_bot.latest".format( + secrets[secrets.APP_CHANNEL] + ), + ] + + index_service = taskcluster_config.get_service("index") + + for namespace in namespaces: + index_service.insertTask( + namespace, + { + "taskId": os.environ["TASK_ID"], + "rank": 0, + "data": {}, + "expires": (datetime.utcnow() + timedelta(180)).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ), + }, + ) + + +def main(): + logger.info("Starting code coverage bot for cron") + args = setup_cli(ask_revision=False, ask_repository=False) + hook = CronHook(args.task_name_filter, args.cache_root) + hook.run() diff --git a/bot/code_coverage_bot/hooks/repo.py b/bot/code_coverage_bot/hooks/repo.py new file mode 100644 index 000000000..bdefbc1ae --- /dev/null +++ b/bot/code_coverage_bot/hooks/repo.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import json +import os +import zipfile + +import structlog + +from code_coverage_bot import config +from code_coverage_bot import hgmo +from code_coverage_bot import uploader +from code_coverage_bot.cli import setup_cli +from code_coverage_bot.hooks.base import Hook +from code_coverage_bot.notifier import notify_email +from code_coverage_bot.phabricator import PhabricatorUploader +from code_coverage_bot.phabricator import parse_revision_id + +logger = structlog.get_logger(__name__) + + +class RepositoryHook(Hook): + """ + Base class to support specific workflows per repository + """ + + def upload_reports(self, reports): + """ + Upload all provided covdir reports on GCP + """ + for (platform, suite), path in reports.items(): + report = open(path, "rb").read() + uploader.gcp( + self.branch, self.revision, report, suite=suite, platform=platform + ) + + def check_javascript_files(self): + """ + Check that all JavaScript files present in the coverage artifacts actually exist. + If they don't, there might be a bug in the LCOV rewriter. + """ + for artifact in self.artifactsHandler.get(): + if "jsvm" not in artifact: + continue + + with zipfile.ZipFile(artifact, "r") as zf: + for file_name in zf.namelist(): + with zf.open(file_name, "r") as fl: + source_files = [ + line[3:].decode("utf-8").rstrip() + for line in fl + if line.startswith(b"SF:") + ] + missing_files = [ + f + for f in source_files + if not os.path.exists(os.path.join(self.repo_dir, f)) + ] + if len(missing_files) != 0: + logger.warn( + f"{missing_files} are present in coverage reports, but missing from the repository" + ) + + def get_hgmo_changesets(self, use_local_clone=True): + """ + Build HGMO changesets according to this repo's configuration + """ + hgmo_config = {} + if use_local_clone: + hgmo_config["repo_dir"] = self.repo_dir + else: + hgmo_config["server_address"] = self.repository + + with hgmo.HGMO(**hgmo_config) as hgmo_server: + return hgmo_server.get_automation_relevance_changesets(self.revision) + + def upload_phabricator(self, report, changesets): + """ + Helper to upload coverage report on Phabricator + """ + phabricatorUploader = PhabricatorUploader(self.repo_dir, self.revision) + logger.info("Upload changeset coverage data to Phabricator") + return phabricatorUploader.upload(report, changesets) + + +class MozillaCentralHook(RepositoryHook): + """ + Code coverage hook for mozilla-central + * Check coverage artifacts content + * Build all covdir reports possible + * Upload all reports on GCP + * Upload main reports on Phabrictaor + * Send an email to admins on low coverage + """ + + def __init__(self, *args, **kwargs): + super().__init__( + config.MOZILLA_CENTRAL_REPOSITORY, + # On mozilla-central, we want to assert that every platform was run (except for android platforms + # as they are unstable). + required_platforms=["linux", "windows"], + *args, + **kwargs, + ) + + def run(self): + # Check the covdir report does not already exists + if uploader.gcp_covdir_exists(self.branch, self.revision, "all", "all"): + logger.warn("Full covdir report already on GCP") + return + + self.retrieve_source_and_artifacts() + + self.check_javascript_files() + + reports = self.build_reports() + logger.info("Built all covdir reports", nb=len(reports)) + + # Retrieve the full report + full_path = reports.get(("all", "all")) + assert full_path is not None, "Missing full report (all:all)" + report = json.load(open(full_path)) + + # Check extensions + paths = uploader.covdir_paths(report) + for extension in [".js", ".cpp"]: + assert any( + path.endswith(extension) for path in paths + ), "No {} file in the generated report".format(extension) + + # Upload reports on GCP + self.upload_reports(reports) + logger.info("Uploaded all covdir reports", nb=len(reports)) + + # Upload coverage on phabricator + changesets = self.get_hgmo_changesets() + coverage = self.upload_phabricator(report, changesets) + + # Send an email on low coverage + notify_email(self.revision, changesets, coverage) + logger.info("Sent low coverage email notification") + + +class TryHook(RepositoryHook): + """ + Code coverage hook for a try push + * Build only main covdir report + * Upload that report on Phabrictaor + """ + + def __init__(self, *args, **kwargs): + super().__init__( + config.TRY_REPOSITORY, + # On try, developers might have requested to run only one platform, and we trust them. + required_platforms=[], + *args, + **kwargs, + ) + + def run(self): + changesets = self.get_hgmo_changesets(use_local_clone=False) + + if not any( + parse_revision_id(changeset["desc"]) is not None for changeset in changesets + ): + logger.info( + "None of the commits in the try push are linked to a Phabricator revision" + ) + return + + self.retrieve_source_and_artifacts() + + reports = self.build_reports(only=[("all", "all")]) + logger.info("Built all covdir reports", nb=len(reports)) + + # Retrieve the full report + full_path = reports.get(("all", "all")) + assert full_path is not None, "Missing full report (all:all)" + report = json.load(open(full_path)) + + # Upload coverage on phabricator + self.upload_phabricator(report, changesets) + + +def main(): + logger.info("Starting code coverage bot for repository") + args = setup_cli() + + hooks = { + config.MOZILLA_CENTRAL_REPOSITORY: MozillaCentralHook, + config.TRY_REPOSITORY: TryHook, + } + hook_class = hooks.get(args.repository) + assert hook_class is not None, f"Unsupported repository {args.repository}" + + hook = hook_class(args.revision, args.task_name_filter, args.cache_root) + hook.run() diff --git a/bot/setup.py b/bot/setup.py index da0ed4315..c0b6491a3 100644 --- a/bot/setup.py +++ b/bot/setup.py @@ -46,6 +46,9 @@ def read_requirements(file_): zip_safe=False, license="MPL2", entry_points={ - "console_scripts": ["code-coverage-bot = code_coverage_bot.cli:main"] + "console_scripts": [ + "code-coverage-cron = code_coverage_bot.hooks.cron:main", + "code-coverage-repo = code_coverage_bot.hooks.repo:main", + ] }, ) diff --git a/bot/taskcluster-hook-cron.json b/bot/taskcluster-hook-cron.json new file mode 100644 index 000000000..e2275142e --- /dev/null +++ b/bot/taskcluster-hook-cron.json @@ -0,0 +1,79 @@ +{ + "bindings": [], + "metadata": { + "description": "Automatically build code coverage reports", + "emailOnError": true, + "name": "Code coverage hook (CHANNEL)", + "owner": "mcastelluccio@mozilla.com" + }, + "schedule": [ + "0 0 0 * * *" + ], + "task": { + "created": { + "$fromNow": "0 seconds" + }, + "deadline": { + "$fromNow": "4 hours" + }, + "expires": { + "$fromNow": "1 month" + }, + "extra": {}, + "metadata": { + "description": "", + "name": "Code Coverage aggregation task - cron (CHANNEL)", + "owner": "mcastelluccio@mozilla.com", + "source": "https://github.com/mozilla/code-coverage" + }, + "payload": { + "artifacts": { + "public/chunk_mapping.tar.xz": { + "path": "/chunk_mapping.tar.xz", + "type": "file" + }, + "public/per_chunk_mapping.tar.xz": { + "path": "/per_chunk_mapping.tar.xz", + "type": "file" + }, + "public/zero_coverage_report.json": { + "path": "/zero_coverage_report.json", + "type": "file" + } + }, + "cache": { + "code-coverage-bot-CHANNEL": "/cache" + }, + "capabilities": {}, + "command": [ + "code-coverage-cron", + "--taskcluster-secret", + "project/relman/code-coverage/runtime-CHANNEL", + "--cache-root", + "/cache" + ], + "env": {}, + "features": { + "taskclusterProxy": true + }, + "image": "mozilla/code-coverage:bot-REVISION", + "maxRunTime": 14400 + }, + "priority": "normal", + "provisionerId": "aws-provisioner-v1", + "retries": 5, + "routes": [], + "schedulerId": "-", + "scopes": [ + "secrets:get:project/relman/code-coverage/runtime-CHANNEL", + "docker-worker:cache:code-coverage-bot-CHANNEL", + "index:insert-task:project.releng.services.project.CHANNEL.code_coverage_bot.*" + ], + "tags": {}, + "workerType": "releng-svc-memory" + }, + "triggerSchema": { + "additionalProperties": true, + "type": "object" + } +} diff --git a/bot/taskcluster-hook.json b/bot/taskcluster-hook-repo.json similarity index 59% rename from bot/taskcluster-hook.json rename to bot/taskcluster-hook-repo.json index d84fc182b..af1c149d9 100644 --- a/bot/taskcluster-hook.json +++ b/bot/taskcluster-hook-repo.json @@ -6,21 +6,14 @@ "name": "Code coverage hook (CHANNEL)", "owner": "mcastelluccio@mozilla.com" }, - "schedule": [ - "0 0 0 * * *" - ], "task": { "$merge": [ { - "$if": "firedBy == 'triggerHook'", + "$if": "'taskGroupId' in payload", "else": {}, "then": { - "$if": "'taskGroupId' in payload", - "else": {}, - "then": { - "taskGroupId": { - "$eval": "payload.taskGroupId" - } + "taskGroupId": { + "$eval": "payload.taskGroupId" } } }, @@ -38,51 +31,29 @@ "metadata": { "description": "", "name": { - "$if": "firedBy == 'triggerHook'", - "else": "Code Coverage aggregation task (CHANNEL)", + "$if": "'taskName' in payload", + "else": "Code Coverage aggregation task - repo (CHANNEL)", "then": { - "$if": "'taskName' in payload", - "else": "Code Coverage aggregation task (CHANNEL)", - "then": { - "$eval": "payload.taskName" - } + "$eval": "payload.taskName" } }, "owner": "mcastelluccio@mozilla.com", "source": "https://github.com/mozilla/code-coverage" }, "payload": { - "artifacts": { - "public/chunk_mapping.tar.xz": { - "path": "/chunk_mapping.tar.xz", - "type": "file" - }, - "public/per_chunk_mapping.tar.xz": { - "path": "/per_chunk_mapping.tar.xz", - "type": "file" - }, - "public/zero_coverage_report.json": { - "path": "/zero_coverage_report.json", - "type": "file" - } - }, "cache": { "code-coverage-bot-CHANNEL": "/cache" }, "capabilities": {}, "command": [ - "code-coverage-bot", + "code-coverage-repo", "--taskcluster-secret", "project/relman/code-coverage/runtime-CHANNEL", "--cache-root", "/cache" ], "env": { - "$if": "firedBy == 'triggerHook'", - "else": {}, - "then": { - "$eval": "payload" - } + "$eval": "payload" }, "features": { "taskclusterProxy": true @@ -98,8 +69,7 @@ "scopes": [ "secrets:get:project/relman/code-coverage/runtime-CHANNEL", "notify:email:*", - "docker-worker:cache:code-coverage-bot-CHANNEL", - "index:insert-task:project.releng.services.project.CHANNEL.code_coverage_bot.*" + "docker-worker:cache:code-coverage-bot-CHANNEL" ], "tags": {}, "workerType": "releng-svc-memory" diff --git a/bot/tests/test_codecov.py b/bot/tests/test_codecov.py deleted file mode 100644 index bdd0662fb..000000000 --- a/bot/tests/test_codecov.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- - -from code_coverage_bot import codecov - - -def test_ok(): - assert codecov diff --git a/bot/tests/test_hook.py b/bot/tests/test_hook.py index 3094ebf90..ed726a5b5 100644 --- a/bot/tests/test_hook.py +++ b/bot/tests/test_hook.py @@ -6,29 +6,33 @@ import jsonschema import pytest -HOOK = os.path.join(os.path.dirname(__file__), "../taskcluster-hook.json") +HOOK_REPO = os.path.join(os.path.dirname(__file__), "../taskcluster-hook-repo.json") +HOOK_CRON = os.path.join(os.path.dirname(__file__), "../taskcluster-hook-cron.json") payloads = [ # Trigger by interface or API - {"firedBy": "triggerHook", "taskId": "xxx", "payload": {}}, - { - "firedBy": "triggerHook", - "taskId": "xxx", - "payload": {"taskName": "Custom task name", "taskGroupId": "yyyy"}, - }, + (HOOK_REPO, {"firedBy": "triggerHook", "taskId": "xxx", "payload": {}}), + ( + HOOK_REPO, + { + "firedBy": "triggerHook", + "taskId": "xxx", + "payload": {"taskName": "Custom task name", "taskGroupId": "yyyy"}, + }, + ), # Cron trigger - {"firedBy": "schedule", "taskId": "xxx"}, + (HOOK_CRON, {"firedBy": "schedule", "taskId": "xxx"}), ] -@pytest.mark.parametrize("payload", payloads) -def test_hook_syntax(payload): +@pytest.mark.parametrize("hook_path, payload", payloads) +def test_hook_syntax(hook_path, payload): """ Validate the Taskcluster hook syntax """ - assert os.path.exists(HOOK) + assert os.path.exists(hook_path) - with open(HOOK, "r") as f: + with open(hook_path, "r") as f: # Patch the hook as in the taskboot deployment content = f.read() content = content.replace("REVISION", "deadbeef1234")