Skip to content

Commit 08f796b

Browse files
authored
bot: build detailed reports for tests suites & platforms (#144)
1 parent 4bb73d8 commit 08f796b

File tree

5 files changed

+275
-133
lines changed

5 files changed

+275
-133
lines changed

bot/code_coverage_bot/artifacts.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# -*- coding: utf-8 -*-
2+
import collections
23
import fnmatch
4+
import itertools
35
import os
46
import time
57

@@ -11,6 +13,9 @@
1113
logger = structlog.get_logger(__name__)
1214

1315

16+
Artifact = collections.namedtuple("Artifact", "path, task_id, platform, suite, chunk")
17+
18+
1419
SUITES_TO_IGNORE = [
1520
"awsy",
1621
"talos",
@@ -25,41 +30,74 @@ def __init__(self, task_ids, parent_dir="ccov-artifacts", task_name_filter="*"):
2530
self.task_ids = task_ids
2631
self.parent_dir = parent_dir
2732
self.task_name_filter = task_name_filter
33+
self.artifacts = []
2834

2935
def generate_path(self, platform, chunk, artifact):
3036
file_name = "%s_%s_%s" % (platform, chunk, os.path.basename(artifact["name"]))
3137
return os.path.join(self.parent_dir, file_name)
3238

3339
def get_chunks(self, platform):
3440
return set(
35-
f.split("_")[1]
36-
for f in os.listdir(self.parent_dir)
37-
if os.path.basename(f).startswith(f"{platform}_")
41+
artifact.chunk
42+
for artifact in self.artifacts
43+
if artifact.platform == platform
3844
)
3945

40-
def get(self, platform=None, suite=None, chunk=None):
41-
files = os.listdir(self.parent_dir)
46+
def get_combinations(self):
47+
# Add the full report
48+
out = collections.defaultdict(list)
49+
out[("all", "all")] = [artifact.path for artifact in self.artifacts]
50+
51+
# Group by suite first
52+
suites = itertools.groupby(
53+
sorted(self.artifacts, key=lambda a: a.suite), lambda a: a.suite
54+
)
55+
for suite, artifacts in suites:
56+
artifacts = list(artifacts)
57+
58+
# List all available platforms
59+
platforms = {a.platform for a in artifacts}
60+
platforms.add("all")
61+
62+
# And list all possible permutations with suite + platform
63+
out[("all", suite)] += [artifact.path for artifact in artifacts]
64+
for platform in platforms:
65+
if platform != "all":
66+
out[(platform, "all")] += [
67+
artifact.path
68+
for artifact in artifacts
69+
if artifact.platform == platform
70+
]
71+
out[(platform, suite)] = [
72+
artifact.path
73+
for artifact in artifacts
74+
if platform == "all" or artifact.platform == platform
75+
]
76+
77+
return out
4278

79+
def get(self, platform=None, suite=None, chunk=None):
4380
if suite is not None and chunk is not None:
4481
raise Exception("suite and chunk can't both have a value")
4582

4683
# Filter artifacts according to platform, suite and chunk.
4784
filtered_files = []
48-
for fname in files:
49-
if platform is not None and not fname.startswith("%s_" % platform):
85+
for artifact in self.artifacts:
86+
if platform is not None and artifact.platform != platform:
5087
continue
5188

52-
if suite is not None and suite not in fname:
89+
if suite is not None and artifact.suite != suite:
5390
continue
5491

55-
if chunk is not None and ("%s_code-coverage" % chunk) not in fname:
92+
if chunk is not None and artifact.chunk != chunk:
5693
continue
5794

58-
filtered_files.append(os.path.join(self.parent_dir, fname))
95+
filtered_files.append(artifact.path)
5996

6097
return filtered_files
6198

6299
def download(self, test_task):
100+
suite = taskcluster.get_suite(test_task["task"])
63101
chunk_name = taskcluster.get_chunk(test_task["task"])
64102
platform_name = taskcluster.get_platform(test_task["task"])
65103
test_task_id = test_task["status"]["taskId"]
@@ -75,6 +113,10 @@ def download(self, test_task):
75113
taskcluster.download_artifact(artifact_path, test_task_id, artifact["name"])
76114
logger.info("%s artifact downloaded" % artifact_path)
77115

116+
self.artifacts.append(
117+
Artifact(artifact_path, test_task_id, platform_name, suite, chunk_name)
118+
)
119+
78120
def is_filtered_task(self, task):
79121
"""
80122
Apply name filter from CLI args on task name

bot/code_coverage_bot/codecov.py

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(self, repository, revision, task_name_filter, cache_root):
4242

4343
temp_dir = tempfile.mkdtemp()
4444
self.artifacts_dir = os.path.join(temp_dir, "ccov-artifacts")
45+
self.reports_dir = os.path.join(temp_dir, "ccov-reports")
4546

4647
self.index_service = taskcluster_config.get_service("index")
4748

@@ -118,27 +119,56 @@ def retrieve_source_and_artifacts(self):
118119
# Thread 2 - Clone repository.
119120
executor.submit(self.clone_repository, self.repository, self.revision)
120121

121-
def generate_covdir(self):
122+
def build_reports(self, only=None):
122123
"""
123-
Build the covdir report using current artifacts
124+
Build all the possible covdir reports using current artifacts
124125
"""
125-
output = grcov.report(
126-
self.artifactsHandler.get(), source_dir=self.repo_dir, out_format="covdir"
127-
)
128-
logger.info("Covdir report generated successfully")
129-
return json.loads(output)
126+
os.makedirs(self.reports_dir, exist_ok=True)
130127

131-
# This function is executed when the bot is triggered at the end of a mozilla-central build.
132-
def go_from_trigger_mozilla_central(self):
133-
# Check the covdir report does not already exists
134-
if uploader.gcp_covdir_exists(self.branch, self.revision):
135-
logger.warn("Covdir report already on GCP")
136-
return
128+
reports = {}
129+
for (
130+
(platform, suite),
131+
artifacts,
132+
) in self.artifactsHandler.get_combinations().items():
137133

138-
self.retrieve_source_and_artifacts()
134+
if only is not None and (platform, suite) not in only:
135+
continue
136+
137+
# Generate covdir report for that suite & platform
138+
logger.info(
139+
"Building covdir suite report",
140+
suite=suite,
141+
platform=platform,
142+
artifacts=len(artifacts),
143+
)
144+
output = grcov.report(
145+
artifacts, source_dir=self.repo_dir, out_format="covdir"
146+
)
147+
148+
# Write output on FS
149+
path = os.path.join(self.reports_dir, f"{platform}.{suite}.json")
150+
with open(path, "wb") as f:
151+
f.write(output)
139152

140-
# Check that all JavaScript files present in the coverage artifacts actually exist.
141-
# If they don't, there might be a bug in the LCOV rewriter.
153+
reports[(platform, suite)] = path
154+
155+
return reports
156+
157+
def upload_reports(self, reports):
158+
"""
159+
Upload all provided covdir reports on GCP
160+
"""
161+
for (platform, suite), path in reports.items():
162+
report = open(path, "rb").read()
163+
uploader.gcp(
164+
self.branch, self.revision, report, suite=suite, platform=platform
165+
)
166+
167+
def check_javascript_files(self):
168+
"""
169+
Check that all JavaScript files present in the coverage artifacts actually exist.
170+
If they don't, there might be a bug in the LCOV rewriter.
171+
"""
142172
for artifact in self.artifactsHandler.get():
143173
if "jsvm" not in artifact:
144174
continue
@@ -161,7 +191,24 @@ def go_from_trigger_mozilla_central(self):
161191
f"{missing_files} are present in coverage reports, but missing from the repository"
162192
)
163193

164-
report = self.generate_covdir()
194+
# This function is executed when the bot is triggered at the end of a mozilla-central build.
195+
def go_from_trigger_mozilla_central(self):
196+
# Check the covdir report does not already exists
197+
if uploader.gcp_covdir_exists(self.branch, self.revision, "all", "all"):
198+
logger.warn("Full covdir report already on GCP")
199+
return
200+
201+
self.retrieve_source_and_artifacts()
202+
203+
self.check_javascript_files()
204+
205+
reports = self.build_reports()
206+
logger.info("Built all covdir reports", nb=len(reports))
207+
208+
# Retrieve the full report
209+
full_path = reports.get(("all", "all"))
210+
assert full_path is not None, "Missing full report (all:all)"
211+
report = json.load(open(full_path))
165212

166213
paths = uploader.covdir_paths(report)
167214
expected_extensions = [".js", ".cpp"]
@@ -170,6 +217,9 @@ def go_from_trigger_mozilla_central(self):
170217
path.endswith(extension) for path in paths
171218
), "No {} file in the generated report".format(extension)
172219

220+
self.upload_reports(reports)
221+
logger.info("Uploaded all covdir reports", nb=len(reports))
222+
173223
# Get pushlog and ask the backend to generate the coverage by changeset
174224
# data, which will be cached.
175225
with hgmo.HGMO(self.repo_dir) as hgmo_server:
@@ -179,9 +229,6 @@ def go_from_trigger_mozilla_central(self):
179229
phabricatorUploader = PhabricatorUploader(self.repo_dir, self.revision)
180230
changesets_coverage = phabricatorUploader.upload(report, changesets)
181231

182-
uploader.gcp(self.branch, self.revision, report)
183-
184-
logger.info("Build uploaded on GCP")
185232
notify_email(self.revision, changesets, changesets_coverage)
186233

187234
# This function is executed when the bot is triggered at the end of a try build.
@@ -201,7 +248,10 @@ def go_from_trigger_try(self):
201248

202249
self.retrieve_source_and_artifacts()
203250

204-
report = self.generate_covdir()
251+
reports = self.build_reports(only=[("all", "all")])
252+
full_path = reports.get(("all", "all"))
253+
assert full_path is not None, "Missing full report (all:all)"
254+
report = json.load(open(full_path))
205255

206256
logger.info("Upload changeset coverage data to Phabricator")
207257
phabricatorUploader.upload(report, changesets)

bot/code_coverage_bot/uploader.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# -*- coding: utf-8 -*-
22
import itertools
3-
import json
43
import os.path
54

65
import requests
@@ -12,25 +11,29 @@
1211
from code_coverage_tools.gcp import get_bucket
1312

1413
logger = structlog.get_logger(__name__)
15-
GCP_COVDIR_PATH = "{repository}/{revision}.json.zstd"
14+
GCP_COVDIR_PATH = "{repository}/{revision}/{platform}:{suite}.json.zstd"
1615

1716

18-
def gcp(repository, revision, report):
17+
def gcp(repository, revision, report, platform, suite):
1918
"""
2019
Upload a grcov raw report on Google Cloud Storage
2120
* Compress with zstandard
2221
* Upload on bucket using revision in name
2322
* Trigger ingestion on channel's backend
2423
"""
25-
assert isinstance(report, dict)
24+
assert isinstance(report, bytes)
25+
assert isinstance(platform, str)
26+
assert isinstance(suite, str)
2627
bucket = get_bucket(secrets[secrets.GOOGLE_CLOUD_STORAGE])
2728

2829
# Compress report
2930
compressor = zstd.ZstdCompressor()
30-
archive = compressor.compress(json.dumps(report).encode("utf-8"))
31+
archive = compressor.compress(report)
3132

3233
# Upload archive
33-
path = GCP_COVDIR_PATH.format(repository=repository, revision=revision)
34+
path = GCP_COVDIR_PATH.format(
35+
repository=repository, revision=revision, platform=platform, suite=suite
36+
)
3437
blob = bucket.blob(path)
3538
blob.upload_from_string(archive)
3639

@@ -42,35 +45,47 @@ def gcp(repository, revision, report):
4245
logger.info("Uploaded {} on {}".format(path, bucket))
4346

4447
# Trigger ingestion on backend
45-
retry(lambda: gcp_ingest(repository, revision), retries=10, wait_between_retries=60)
48+
retry(
49+
lambda: gcp_ingest(repository, revision, platform, suite),
50+
retries=10,
51+
wait_between_retries=60,
52+
)
4653

4754
return blob
4855

4956

50-
def gcp_covdir_exists(repository, revision):
57+
def gcp_covdir_exists(repository, revision, platform, suite):
5158
"""
5259
Check if a covdir report exists on the Google Cloud Storage bucket
5360
"""
5461
bucket = get_bucket(secrets[secrets.GOOGLE_CLOUD_STORAGE])
55-
path = GCP_COVDIR_PATH.format(repository=repository, revision=revision)
62+
path = GCP_COVDIR_PATH.format(
63+
repository=repository, revision=revision, platform=platform, suite=suite
64+
)
5665
blob = bucket.blob(path)
5766
return blob.exists()
5867

5968

60-
def gcp_ingest(repository, revision):
69+
def gcp_ingest(repository, revision, platform, suite):
6170
"""
6271
The GCP report ingestion is triggered remotely on a backend
6372
by making a simple HTTP request on the /v2/path endpoint
6473
By specifying the exact new revision processed, the backend
6574
will download automatically the new report.
6675
"""
6776
params = {"repository": repository, "changeset": revision}
77+
if platform:
78+
params["platform"] = platform
79+
if suite:
80+
params["suite"] = suite
6881
backend_host = secrets[secrets.BACKEND_HOST]
6982
logger.info(
7083
"Ingesting report on backend",
7184
host=backend_host,
7285
repository=repository,
7386
revision=revision,
87+
platform=platform,
88+
suite=suite,
7489
)
7590
resp = requests.get("{}/v2/path".format(backend_host), params=params)
7691
resp.raise_for_status()

0 commit comments

Comments
 (0)