blob: ab8da9165bc1747ebcdbbfff61ea5b1390fb96c9 [file]
#!/usr/bin/env vpython3
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Fetches per-CL code coverage metrics and uncovered lines from FindIt API."""
import argparse
import base64
import json
import os
import pathlib
import sys
import urllib.parse
from typing import Any
import requests
def make_request(url: str, auth_token: str | None = None) -> bytes | None:
"""Fetches URL data via requests with optional Bearer token auth.
Args:
url: The absolute HTTP/HTTPS URL to request.
auth_token: Optional Bearer OAuth token string.
Returns:
The raw response bytes if successful, or None on failure.
"""
headers = {}
if auth_token:
headers['Authorization'] = f'Bearer {auth_token}'
try:
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
return resp.content
except requests.RequestException as e:
print(f'Error fetching {url}: {e}', file=sys.stderr)
return None
def fetch_gerrit_file_lines(
host: str,
change: int,
patchset: int,
file_path: str,
auth_token: str | None = None,
) -> dict[str, str]:
"""Fetches base64 file content from Gerrit REST API and returns line map.
Args:
host: Gerrit hostname string.
change: Gerrit CL integer number.
patchset: Gerrit patchset integer number.
file_path: Relative path string to the target file.
auth_token: Optional Bearer OAuth token string.
Returns:
A dictionary mapping 1-based line number strings to source text lines.
"""
encoded_path = urllib.parse.quote(file_path, safe='')
url = (f'https://{host}/changes/{change}/revisions/{patchset}'
f'/files/{encoded_path}/content')
data = make_request(url, auth_token)
if not data:
return {}
try:
decoded = base64.b64decode(data).decode('utf-8', errors='replace')
lines = decoded.splitlines()
return {str(num): line for num, line in enumerate(lines, start=1)}
except Exception as e: # pylint: disable=broad-except
print(f'Error decoding file {file_path}: {e}', file=sys.stderr)
return {}
def parse_percentage(val: Any) -> float | None:
"""Calculates percentage float from ratio dict or scalar value.
Args:
val: A coverage ratio dict or numeric string/float.
Returns:
The calculated coverage percentage float, or None if invalid.
"""
if val is None:
return None
if isinstance(val, dict):
covered = val.get('covered', 0)
total = val.get('total', 0)
if total > 0:
return round((covered / total) * 100.0, 2)
return 100.0
try:
return float(val)
except (ValueError, TypeError):
return None
def query_uncovered_lines(
base_url: str,
base_params: dict[str, str],
path: str,
token: str | None,
host: str,
change: int,
patchset: int,
) -> dict[str, str]:
"""Queries FindIt lines API for a file and extracts uncovered lines.
Args:
base_url: FindIt API endpoint string.
base_params: Base query parameters dict.
path: File relative path string.
token: Optional OAuth token.
host: Gerrit host.
change: CL number.
patchset: Patchset number.
Returns:
Dictionary mapping uncovered line number strings to code text.
"""
lines_params = {**base_params, 'type': 'lines', 'path': path}
lines_url = f'{base_url}?{urllib.parse.urlencode(lines_params)}'
l_bytes = make_request(lines_url, token)
uncovered_dict = {}
if not l_bytes:
return uncovered_dict
try:
l_json = json.loads(l_bytes.decode('utf-8'))
l_files = l_json.get('data', {}).get('files', [])
target_f = None
if isinstance(l_files, dict):
target_f = l_files.get(path)
elif isinstance(l_files, list):
target_f = next((lf for lf in l_files if lf.get('path') == path), None)
if target_f and 'lines' in target_f:
file_lines = fetch_gerrit_file_lines(host, change, patchset, path, token)
for line_info in target_f['lines']:
if line_info.get('count') == 0:
l_num = str(line_info.get('line'))
uncovered_dict[l_num] = file_lines.get(l_num, '')
except Exception as e: # pylint: disable=broad-except
print(f'Error processing lines for {path}: {e}', file=sys.stderr)
return uncovered_dict
def process_files(
files_iterable: list[dict[str, Any]],
base_url: str,
base_params: dict[str, str],
token: str | None,
host: str,
change: int,
patchset: int,
target_files: set[str] | None = None,
) -> dict[str, Any]:
"""Processes coverage metrics and lines for all files in percentages report.
Args:
files_iterable: List of file info dicts.
base_url: FindIt API base endpoint.
base_params: Query params dict.
token: Optional auth token.
host: Gerrit host.
change: CL number.
patchset: Patchset number.
target_files: Optional set of file paths to filter processing for.
Returns:
Results dict mapping file paths to coverage details.
"""
metrics_keys = [
'absolute_coverage',
'incremental_coverage',
'absolute_unit_tests_coverage',
'incremental_unit_tests_coverage',
]
results = {}
for f_info in files_iterable:
path = f_info.get('path')
if not path or (target_files is not None and path not in target_files):
continue
f_metrics = {}
low_types = []
for k in metrics_keys:
pct = parse_percentage(f_info.get(k))
if pct is not None:
f_metrics[k] = pct
if pct < 100.0:
low_types.append(k)
uncovered = {}
if low_types:
uncovered = query_uncovered_lines(base_url, base_params, path, token,
host, change, patchset)
results[path] = {
'metrics': f_metrics,
'low_coverage_type': low_types,
'uncovered_lines': uncovered,
}
return results
def main() -> None:
"""Parses CLI arguments, queries FindIt API, and writes coverage report."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', required=True, help='Gerrit hostname')
parser.add_argument('--project', required=True, help='Repository name')
parser.add_argument('--change', required=True, type=int, help='CL number')
parser.add_argument('--patchset', required=True, type=int, help='Patchset')
parser.add_argument('--output',
type=pathlib.Path,
help='Output artifact file path')
parser.add_argument('--files',
nargs='*',
help='Optional target file paths to filter coverage for')
parser.add_argument('--auth-token', help='Optional LUCI OAuth token')
args = parser.parse_args()
token = args.auth_token or os.environ.get('LUCI_AUTH_TOKEN')
base_url = 'https://findit-for-me.appspot.com/coverage/api/coverage-data'
params = {
'host': args.host,
'project': args.project,
'change': str(args.change),
'patchset': str(args.patchset),
'type': 'percentages',
'concise': '1',
'format': 'json',
}
url = f'{base_url}?{urllib.parse.urlencode(params)}'
resp_bytes = make_request(url, token)
if not resp_bytes:
print('Failed to fetch coverage percentages.', file=sys.stderr)
sys.exit(1)
try:
percentages_data = json.loads(resp_bytes.decode('utf-8'))
except Exception as e: # pylint: disable=broad-except
print(f'Failed to parse percentages JSON: {e}', file=sys.stderr)
sys.exit(1)
files_data = percentages_data.get('data', {}).get('files', [])
if isinstance(files_data, dict):
files_iterable = [{
'path': k,
**v
} if isinstance(v, dict) else {
'path': k
} for k, v in files_data.items()]
else:
files_iterable = files_data
target_files = set(args.files) if args.files else None
results = process_files(files_iterable, base_url, params, token, args.host,
args.change, args.patchset, target_files)
out_str = json.dumps(results, indent=2)
if args.output:
args.output.write_text(out_str, encoding='utf-8')
else:
print(out_str)
if __name__ == '__main__':
main()