blob: cf76a34280028fcf660187754519a08ca9327611 [file] [log] [blame]
/** @license
* Copyright 2020 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*/
import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
import {
CoverageRange,
CoverageType,
Side,
} from '@gerritcodereview/typescript-api/diff';
import {
Category,
CheckResult,
FetchResponse,
ResponseCode,
RunStatus,
} from '@gerritcodereview/typescript-api/checks';
import {
ChangeInfo,
RevisionInfo,
} from '@gerritcodereview/typescript-api/rest-api';
// Url suffix of the code coverage service API from which to fetch per-cl
// coverage data.
const COVERAGE_SERVICE_ENDPOINT_SUFFIX = '/coverage/api/coverage-data';
// The gob coverage host which is used to process internal project.
const GOB_COVERAGE_HOST = 'https://gob-coverage.googleplex.com';
// The chromium coverage host which is used to process external project.
const CHROMIUM_COVERAGE_HOST = 'https://findit-for-me.appspot.com';
// Dict of gerrit review host and corresponding code coverage service host
// from which to fetch per-cl coverage data.
const GERRIT_TO_COVERAGE_HOST: {[host: string]: string} = {
'chromium-review.git.corp.google.com': CHROMIUM_COVERAGE_HOST,
'chromium-review.googlesource.com': CHROMIUM_COVERAGE_HOST,
'libassistant-internal-review.git.corp.google.com': GOB_COVERAGE_HOST,
'libassistant-internal-review.googlesource.com': GOB_COVERAGE_HOST,
};
// Used to identify host prefixes that should be stripped. This is needed
// so that the plugin can work in different environments, such as 'canary-'.
const HOST_PREFIXES = ['canary-', 'polymer1-', 'polymer2-'];
// Bar for low coverage warning,
const OVERALL_LOW_COVERAGE_WARNING_BAR = 70;
const LOW_COVERAGE_REASON_PREFIXES = [
'TRIVIAL_CHANGE', 'TESTS_ARE_DISABLED', 'TESTS_IN_SEPARATE_CL',
'HARD_TO_TEST', 'COVERAGE_UNDERREPORTED', 'LARGE_SCALE_REFACTOR',
'EXPERIMENTAL_CODE', 'OTHER'
]
declare interface CoverageConfig {
project: string; // Used to validate/invalidate the cache.
// Used to indicate an async fetch of per-project configuration
configPromise: Promise<{enabled: boolean}> | null;
}
declare interface CoverageChangeInfo {
host: string;
project: string;
changeNum: number;
patchNum: number | undefined;
}
/**
* Description of Code coverage for a file.
*
* @param absolute Coverage percentage of all lines.
* @param incremental Coverage percentages of added lines.
* @param absolute_unit_tests Coverage percentage of all lines of unittests.
* @param incremental_unit_tests Coverage percentages of added unitetest lines.
*/
export declare interface PercentageData {
absolute?: number;
incremental?: number;
absolute_unit_tests?: number;
incremental_unit_tests?: number;
}
declare interface CoverageData {
changeInfo: CoverageChangeInfo;
rangesPromise: Promise<{[path: string]: CoverageRange[]} | null>;
percentagesPromise: Promise<{[path: string]: PercentageData} | null>;
}
declare interface LinesCoverage {
line: number;
count: number;
}
declare interface PctCoverage {
covered: number;
total: number;
}
declare interface CoverageFilesResponse {
path: string;
lines?: LinesCoverage[];
absolute_coverage?: PctCoverage;
incremental_coverage?: PctCoverage;
absolute_unit_tests_coverage?: PctCoverage;
incremental_unit_tests_coverage?: PctCoverage;
}
/**
* JSON data of coverage.
*
* @param data Coverage, keyed by file
*/
export declare interface CoverageResponse {
data: {files: CoverageFilesResponse[]};
}
/**
* Provides APIs to fetch and cache coverage data.
*/
export class CoverageClient {
plugin: PluginApi;
coverageConfig: CoverageConfig | null;
coverageData: CoverageData | null;
constructor(plugin: PluginApi) {
this.provideCoverageRanges = this.provideCoverageRanges.bind(this);
this.prefetchCoverageRanges = this.prefetchCoverageRanges.bind(this);
this.provideCoveragePercentages =
this.provideCoveragePercentages.bind(this);
this.plugin = plugin;
this.coverageConfig = null;
this.coverageData = null;
}
/**
* Gets the normalized host name.
*
* @param host The host name of the window location.
*/
getNormalizedHost(host: string): string {
for (const prefix of HOST_PREFIXES) {
if (host.startsWith(prefix)) {
host = host.substring(prefix.length);
break;
}
}
return host;
}
/**
* Parses project name from the path name.
*
* The path name is expected to be in one of the following forms:
* '/c/chromium/src/+/1369646'
* '/c/chromium/src/+/1369646/3'
* '/c/chromium/src/+/1369646/3/base/base/test.cc'
*
* @param pathName The path name of the window location.
* @return Returns current project such as chromium/src if the url
* is valid, otherwise, returns null.
*/
parseProjectFromPathName(pathName: string): string {
if (!pathName.startsWith('/c/')) {
throw new Error(`${pathName} is expected to start with "/c/"`);
}
const indexEnd = pathName.indexOf('/+');
if (indexEnd === -1) {
throw new Error(`${pathName} is expected to contain "/+"`);
}
return pathName.substring(3, indexEnd);
}
/**
* Fetches code coverage data from coverage service for a patchset.
* The value of 'incremental_coverage' is null if there are no added lines.
*/
async fetchCoverageJsonData(
changeInfo: CoverageChangeInfo,
kind: string
): Promise<CoverageResponse> {
if (kind !== 'lines' && kind !== 'percentages') {
throw new Error('Kind is expected to be either "lines" or "percentages"');
}
if (changeInfo.patchNum === undefined) {
throw new Error('Patch number is undefined, cannot get coverage data');
}
const params = new URLSearchParams({
// Only googlesource hosts are supported.
host: changeInfo.host.replace('git.corp.google', 'googlesource'),
project: changeInfo.project,
change: changeInfo.changeNum.toString(),
patchset: changeInfo.patchNum.toString(),
type: kind,
format: 'json',
concise: '1',
});
let coverageHost = GERRIT_TO_COVERAGE_HOST[changeInfo.host];
// If the host is not found, use CHROMIUM_COVERAGE_HOST by default.
if (!coverageHost) {
coverageHost = CHROMIUM_COVERAGE_HOST;
}
const endpoint = coverageHost + COVERAGE_SERVICE_ENDPOINT_SUFFIX;
const url = `${endpoint}?${params.toString()}`;
const response = await fetch(url);
const responseJson = await response.json();
if (
response.status === 400 &&
responseJson.is_project_supported === false
) {
throw new Error(
`"${changeInfo.project}" project is not supported for code coverage`
);
}
if (response.status === 500 && responseJson.is_service_enabled === false) {
throw new Error('Code coverage service is temporarily disabled');
}
if (!response.ok) {
throw new Error(
`Request code coverage data returned http ${response.status}`
);
}
return responseJson;
}
/**
* Converts the JSON response to coverage ranges needed by coverage layer.
*
* @param responseJson The JSON response returned from coverage
* service.
* @return Returns an object whose properties are file paths and
* corresponding values are a list of coverage ranges if the JSON
* response has valid format, otherwise, returns null.
*/
convertResponseJsonToCoverageRanges(responseJson: CoverageResponse): {
[path: string]: CoverageRange[];
} {
if (!responseJson.data) {
throw new Error(
'Invalid coverage lines response format. Expecting "data" property'
);
}
const responseData = responseJson.data;
if (!responseData.files) {
throw new Error(
'Invalid coverage lines response format. Expecting "files" property'
);
}
const responseFiles = responseData.files;
const coverageRanges: {[path: string]: CoverageRange[]} = {};
for (let i = 0; i < responseFiles.length; i++) {
const responseFile = responseFiles[i];
if (!responseFile.path || !responseFile.lines) {
throw new Error(
'Invalid coverage lines response format. Expecting ' +
'"path" and "lines" properties'
);
}
coverageRanges[responseFile.path] = [];
const responseLines = responseFile.lines;
responseLines.sort((a, b) => (a.line > b.line ? 1 : -1));
let startLine = -1;
let endLine = -1;
let isCovered = null;
for (let j = 0; j < responseLines.length; j++) {
const responseLine = responseLines[j];
if (!responseLine.line || responseLine.count === null) {
throw new Error(
'Invalid coverage lines response format. ' +
'Expecting "line" and "count" properties'
);
}
if (
startLine !== -1 &&
responseLine.line === endLine + 1 &&
isCovered === responseLine.count > 0
) {
endLine += 1;
continue;
}
if (startLine !== -1) {
coverageRanges[responseFile.path].push({
side: Side.RIGHT,
type: isCovered ? CoverageType.COVERED : CoverageType.NOT_COVERED,
code_range: {
start_line: startLine,
end_line: endLine,
},
});
}
startLine = responseLine.line;
endLine = startLine;
isCovered = responseLine.count > 0;
}
if (startLine !== -1) {
coverageRanges[responseFile.path].push({
side: Side.RIGHT,
type: isCovered ? CoverageType.COVERED : CoverageType.NOT_COVERED,
code_range: {
start_line: startLine,
end_line: endLine,
},
});
}
}
return coverageRanges;
}
/**
* Fetches code coverage ranges from coverage service for a patchset.
*
* @param changeInfo Has host, project, changeNum and patchNum.
* @return Resolves to a map of files to coverage ranges if the
* coverage ata is successfully retrieved and parsed, otherwise,
* resolves to null.
*/
async fetchCoverageRanges(
changeInfo: CoverageChangeInfo
): Promise<{[path: string]: CoverageRange[]}> {
const responseJson = await this.fetchCoverageJsonData(changeInfo, 'lines');
return this.convertResponseJsonToCoverageRanges(responseJson);
}
/**
* Converts the JSON response to coverage percentages.
*
* @param responseJson The JSON response returned from coverage
* service.
* @return Returns an object whose properties are file paths and
* corresponding values are objects representing absolute and
* incremental coverage percentages if the JSON response has valid
* format, otherwise, returns null.
*/
convertResponseJsonToCoveragePercentages(responseJson: CoverageResponse): {
[path: string]: PercentageData;
} {
if (!responseJson.data) {
throw new Error(
'Invalid coverage percentages response format. ' +
'Expecting "data" property'
);
}
const responseData = responseJson.data;
if (!responseData.files) {
throw new Error(
'Invalid coverage percentages response format. ' +
'Expecting "files" property'
);
}
const coveragePercentages: {[path: string]: PercentageData} = {};
for (const responseFile of responseData.files) {
// TODO(crbug/1424854): Also check for absolute coverage
if (!responseFile.path) {
throw new Error(
'Invalid coverage percentages response format. ' +
'Expecting "path" property'
);
}
const fileCov: PercentageData = {};
if (responseFile.absolute_coverage) {
fileCov.absolute = Math.round(
(responseFile.absolute_coverage.covered * 100) /
responseFile.absolute_coverage.total
);
}
if (responseFile.incremental_coverage) {
fileCov.incremental = Math.round(
(responseFile.incremental_coverage.covered * 100) /
responseFile.incremental_coverage.total
);
}
if (responseFile.absolute_unit_tests_coverage) {
fileCov.absolute_unit_tests = Math.round(
(responseFile.absolute_unit_tests_coverage.covered * 100) /
responseFile.absolute_unit_tests_coverage.total
);
}
if (responseFile.incremental_unit_tests_coverage) {
fileCov.incremental_unit_tests = Math.round(
(responseFile.incremental_unit_tests_coverage.covered * 100) /
responseFile.incremental_unit_tests_coverage.total
);
}
coveragePercentages[responseFile.path] = fileCov;
}
return coveragePercentages;
}
/**
* Fetches code coverage percentages from coverage service for a patchset.
*
* @param changeInfo Has host, project, changeNum and patchNum.
* @return Resolves to a map of files to coverage percentages if
* coverage data is successfully retrieved and parsed, otherwise,
* resolves to null.
*/
async fetchCoveragePercentages(
changeInfo: CoverageChangeInfo
): Promise<{[path: string]: PercentageData}> {
const responseJson = await this.fetchCoverageJsonData(
changeInfo,
'percentages'
);
return this.convertResponseJsonToCoveragePercentages(responseJson);
}
/**
* Fetches code coverage ranges from coverage service for a patchset.
*
* @param changeInfo Has host, project, changeNum and patchNum.
*/
updateCoverageDataIfNecessary(changeInfo: CoverageChangeInfo) {
if (
changeInfo.patchNum === undefined ||
isNaN(changeInfo.changeNum) ||
isNaN(changeInfo.patchNum) ||
changeInfo.changeNum <= 0 ||
changeInfo.patchNum <= 0
) {
return;
}
if (
!this.coverageData ||
JSON.stringify(changeInfo) !==
JSON.stringify(this.coverageData.changeInfo)
) {
this.coverageData = {
changeInfo,
rangesPromise: this.fetchCoverageRanges(changeInfo),
percentagesPromise: this.fetchCoveragePercentages(changeInfo),
};
this.coverageData.rangesPromise.catch(error => {
console.warn(error);
});
this.coverageData.percentagesPromise.catch(error => {
console.warn(error);
});
}
}
/**
* Provides code coverage ranges for a file of a patchset.
*
* @param changeNum The change number of the patchset.
* @param path The relative path to the file.
* @param basePatchNum The patchset number of the base patchset.
* @param patchNum The patchset number of the patchset.
* @return Returns a list of coverage ranges. On error, it logs the
* error and returns null/undefined.
*/
async provideCoverageRanges(
changeNum: number,
path: string,
basePatchNum: number | undefined,
patchNum: number | undefined
): Promise<CoverageRange[] | undefined> {
const changeInfo: CoverageChangeInfo = {
host: this.getNormalizedHost(window.location.host),
project: this.parseProjectFromPathName(window.location.pathname),
changeNum,
patchNum,
};
this.updateCoverageDataIfNecessary(changeInfo);
try {
if (this.coverageData) {
const coverageRanges = await this.coverageData.rangesPromise;
if (coverageRanges) {
return coverageRanges[path] || [];
}
return [];
} else {
return undefined;
}
} catch (error: unknown) {
console.info(error);
return undefined;
}
}
/**
* Prefetch coverage ranges.
*
* This method is supposed to be triggered by the 'showchange' event.
*
* @param change Info of the current change.
* @param revision Info of the current revision.
*/
prefetchCoverageRanges(change: ChangeInfo, revision: RevisionInfo) {
let patchNum = NaN;
if (typeof revision._number === 'number') {
patchNum = revision._number;
}
const changeInfo: CoverageChangeInfo = {
host: this.getNormalizedHost(window.location.host),
project: change.project,
changeNum: change._number,
patchNum,
};
this.updateCoverageDataIfNecessary(changeInfo);
}
/**
* Provides code coverage percentage for a file of a patchset.
*
* @param changeNum The change number of the patchset.
* @param path The relative path to the file.
* @param patchNum The patchset number of the patchset.
* @param type Type of percentage: "absolute" or "incremental".
* @return Returns an object representing the absolute and
* incremental coverages. On error, it logs the error and returns
* null/undefined.
*/
async provideCoveragePercentages(
changeNum: string,
path: string,
patchNum: string
): Promise<PercentageData | null> {
const changeInfo: CoverageChangeInfo = {
host: this.getNormalizedHost(window.location.host),
project: this.parseProjectFromPathName(window.location.pathname),
changeNum: Number(changeNum),
patchNum: Number(patchNum),
};
this.updateCoverageDataIfNecessary(changeInfo);
try {
if (this.coverageData) {
const coveragePercentages = await this.coverageData.percentagesPromise;
if (coveragePercentages) {
return coveragePercentages[path];
} else {
return null;
}
} else {
return null;
}
} catch (error: unknown) {
console.info(error);
return null;
}
}
/**
* Extracts low coverage reason from commit message.
*/
getLowCoverageReason(commitMessage?: string): string|undefined {
if(!commitMessage){
return undefined;
}
const re = /Low-Coverage-Reason:(.*)/g;
const matches = [...commitMessage.matchAll(re)];
if (matches.length === 0 || matches[0].length === 0) {
return undefined;
}
const m = matches[0];
const reason = m[m.length - 1].toString().trim();
if (!reason) {
return undefined;
}
return reason;
}
/**
* Surfaces a warning if there are files with low coverage in the patchset.
*
* @param changeNum The change number of the patchset.
* @param patchNum The patchset number of the patchset.
* @return Returns an object representing the warnings.
* On error, it logs the error and returns null/undefined.
*/
async mayBeShowLowCoverageAlert(
changeNum: number,
patchNum: number,
commitMessage?: string
): Promise<FetchResponse> {
const changeInfo: CoverageChangeInfo = {
host: this.getNormalizedHost(window.location.host),
project: this.parseProjectFromPathName(window.location.pathname),
changeNum,
patchNum,
};
this.updateCoverageDataIfNecessary(changeInfo);
try {
if (!this.coverageData) {
return {
// This should never happen
responseCode: ResponseCode.OK,
runs: [],
};
}
const coveragePercentages =
(await this.coverageData.percentagesPromise) || {};
const reason = this.getLowCoverageReason(commitMessage);
const low_coverage_alerts: CheckResult[] = [];
for (const file of Object.keys(coveragePercentages)) {
const incremental = coveragePercentages[file].incremental;
if (incremental !== undefined && incremental < OVERALL_LOW_COVERAGE_WARNING_BAR) {
const msg = `Incremental coverage(All tests) for ${file} `.concat(
`is ${incremental} which is below the bar. `)
const alert = {
category: Category.WARNING,
summary: `Incremental coverage(All Tests) < ${OVERALL_LOW_COVERAGE_WARNING_BAR}%`,
message: msg.concat(
'Please add tests for uncovered lines or add',
' Low-Coverage-Reason:<reason> in change description'
),
}
// Change alert from WARNING to INFO if a reason is provided
if(reason){
alert.category = Category.INFO
alert.message = msg.concat('Check skipped because Low-Coverage-Reason provided')
}
low_coverage_alerts.push(alert);
}
}
const low_coverage_reason_not_formatted_alerts: CheckResult[] = [];
console.log(reason)
if(reason){
console.log("in reason if block")
var isFormatted = false;
if (LOW_COVERAGE_REASON_PREFIXES.some(v => reason.startsWith(v))) {
isFormatted = true;
}
console.log(isFormatted);
if(!isFormatted)
{
const msg = `Reason specified for low coverage for this CL is - ${reason} - `.concat(
`which is missing the category string. `).concat(
`Reason string should begin with one of the following `).concat(
`categories - ${LOW_COVERAGE_REASON_PREFIXES.join(', ')}. `).concat(
`For e.g. "Low-Coverage-Reason: TRIVIAL_CHANGE This change only `).concat(
`adds log statements". See https://bit.ly/46jhjS9 for more info. `).concat(
`If you think none of the reason `).concat(
`categories are sufficent, please open a bug with Infra>Test>CodeCoverage`
)
const alert = {
category: Category.WARNING,
summary: 'Low-Coverage-Reason footer is not properly formatted',
message: msg
}
low_coverage_reason_not_formatted_alerts.push(alert)
}
}
const responseRuns = []
if (low_coverage_alerts.length > 0) {
responseRuns.push( {
checkName: 'Code Coverage Check',
status: RunStatus.COMPLETED,
results: low_coverage_alerts,
})
}
if (low_coverage_reason_not_formatted_alerts.length > 0) {
responseRuns.push({
checkName: 'Low-Coverage-Reason Format Check',
status: RunStatus.COMPLETED,
results: low_coverage_reason_not_formatted_alerts,
})
}
return {
responseCode: ResponseCode.OK,
runs: responseRuns,
};
} catch (error: unknown) {
console.info(error);
return {
// TODO: Make sure that a repo not being supported is not treated as an
// error. And then report realy errors as WARNING.
responseCode: ResponseCode.OK,
runs: [],
};
}
}
/**
* Returns whether to show percentage columns for the current change.
*
* @return Resolves to true if to show the percentage
* columns, otherwise, false.
*/
async showPercentageColumns(): Promise<boolean> {
// This method is expected to be called when percentage columns are
// attached, which means that the current page is at change view and that
// the current project can be parsed from the current URL.
const project = this.parseProjectFromPathName(window.location.pathname);
if (!this.coverageConfig || project !== this.coverageConfig.project) {
this.coverageConfig = {
project,
configPromise: this.plugin
.restApi()
.get(
`/projects/${encodeURIComponent(project)}/` +
`${encodeURIComponent(this.plugin.getPluginName())}~config`
),
};
}
try {
const config = await this.coverageConfig.configPromise;
return config !== null && config.enabled;
} catch (error: unknown) {
console.info(error);
return false;
}
}
}