mirror of
https://github.com/github/codeql-action.git
synced 2026-11-08 14:09:51 +00:00
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import * as fs from "fs";
|
|
|
|
import * as actionsUtil from "../actions-util";
|
|
import type { PullRequestBranches } from "../actions-util";
|
|
import { getApiClient, getGitHubVersion } from "../api-client";
|
|
import type { CodeQL } from "../codeql";
|
|
import { Feature, FeatureEnablement } from "../feature-flags";
|
|
import { Logger } from "../logging";
|
|
import { getRepositoryNwoFromEnv } from "../repository";
|
|
import { getErrorMessage, GitHubVariant, satisfiesGHESVersion } from "../util";
|
|
|
|
/**
|
|
* This interface is an abbreviated version of the file diff object returned by
|
|
* the GitHub API.
|
|
*/
|
|
export interface FileDiff {
|
|
/** The name of the file that has changed. */
|
|
filename: string;
|
|
/** The number of changes. */
|
|
changes: number;
|
|
/**
|
|
* The diff, if available. It may be absent if the file is binary, if the file diff is too large,
|
|
* or if the file is unchanged.
|
|
*/
|
|
patch?: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Get the branches to use for diff-informed analysis.
|
|
*
|
|
* @returns If the action should perform diff-informed analysis, return
|
|
* the base and head branches that should be used to compute the diff ranges.
|
|
* Otherwise return `undefined`.
|
|
*/
|
|
export async function getDiffInformedAnalysisBranches(
|
|
codeql: CodeQL,
|
|
features: FeatureEnablement,
|
|
logger: Logger,
|
|
): Promise<PullRequestBranches | undefined> {
|
|
if (!(await features.getValue(Feature.DiffInformedQueries, codeql))) {
|
|
return undefined;
|
|
}
|
|
|
|
const gitHubVersion = await getGitHubVersion();
|
|
if (
|
|
gitHubVersion.type === GitHubVariant.GHES &&
|
|
satisfiesGHESVersion(gitHubVersion.version, "<3.19", true)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const branches = actionsUtil.getPullRequestBranches();
|
|
if (!branches) {
|
|
logger.info(
|
|
"Not performing diff-informed analysis " +
|
|
"because we are not analyzing a pull request.",
|
|
);
|
|
}
|
|
return branches;
|
|
}
|
|
|
|
/**
|
|
* Prepares the diff ranges needed for diff-informed analysis for the current
|
|
* run.
|
|
*
|
|
* @returns `true` if the diff ranges were successfully computed and persisted
|
|
* and are therefore available for use, `false` otherwise.
|
|
*/
|
|
export async function prepareDiffInformedAnalysis(
|
|
codeql: CodeQL,
|
|
features: FeatureEnablement,
|
|
logger: Logger,
|
|
): Promise<boolean> {
|
|
let branches: PullRequestBranches | undefined;
|
|
try {
|
|
branches = await getDiffInformedAnalysisBranches(codeql, features, logger);
|
|
} catch (e) {
|
|
// If we cannot determine whether diff-informed analysis applies (for
|
|
// example, because a feature-flag lookup failed), treat it as not
|
|
// applicable rather than triggering the overlay fallback.
|
|
logger.warning(
|
|
`Failed to determine branch information for diff-informed analysis: ${getErrorMessage(e)}`,
|
|
);
|
|
return false;
|
|
}
|
|
if (!branches) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await computeAndPersistDiffRanges(branches, logger);
|
|
} catch (e) {
|
|
logger.warning(
|
|
`Failed to compute diff-informed analysis ranges: ${getErrorMessage(e)}`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Describes a changed line range in a file. */
|
|
export interface DiffThunkRange {
|
|
/** Relative path from the repository root, using forward slashes as separators. */
|
|
path: string;
|
|
/** The starting line of the change (1-based and inclusive). */
|
|
startLine: number;
|
|
/** The last line of the change (1-based and inclusive). */
|
|
endLine: number;
|
|
}
|
|
|
|
/**
|
|
* Writes an array of {@link DiffThunkRange} objects to the file given by
|
|
* {@link actionsUtil.getDiffRangesJsonFilePath}.
|
|
*
|
|
* @param logger The logger to use.
|
|
* @param ranges The changed line ranges to write.
|
|
*/
|
|
export function writeDiffRangesJsonFile(
|
|
logger: Logger,
|
|
ranges: DiffThunkRange[],
|
|
): void {
|
|
const jsonContents = JSON.stringify(ranges, null, 2);
|
|
const jsonFilePath = actionsUtil.getDiffRangesJsonFilePath();
|
|
fs.writeFileSync(jsonFilePath, jsonContents);
|
|
logger.debug(
|
|
`Wrote pr-diff-range JSON file to ${jsonFilePath}:\n${jsonContents}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reads an array of {@link DiffThunkRange} objects from the file given by
|
|
* {@link actionsUtil.getDiffRangesJsonFilePath}.
|
|
*
|
|
* @param logger The logger to use.
|
|
* @returns The array of {@link DiffThunkRange} objects or `undefined` if the file couldn't be read.
|
|
*/
|
|
export function readDiffRangesJsonFile(
|
|
logger: Logger,
|
|
): DiffThunkRange[] | undefined {
|
|
const jsonFilePath = actionsUtil.getDiffRangesJsonFilePath();
|
|
if (!fs.existsSync(jsonFilePath)) {
|
|
logger.debug(`Diff ranges JSON file does not exist at ${jsonFilePath}`);
|
|
return undefined;
|
|
}
|
|
const jsonContents = fs.readFileSync(jsonFilePath, "utf8");
|
|
logger.debug(
|
|
`Read pr-diff-range JSON file from ${jsonFilePath}:\n${jsonContents}`,
|
|
);
|
|
try {
|
|
return JSON.parse(jsonContents) as DiffThunkRange[];
|
|
} catch (e) {
|
|
logger.warning(
|
|
`Failed to parse diff ranges JSON file at ${jsonFilePath}: ${e}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the file line ranges that were added or modified in the pull request.
|
|
*
|
|
* @param branches The base and head branches of the pull request.
|
|
* @param logger The logger to use.
|
|
*
|
|
* @returns
|
|
* An array of {@link DiffThunkRange} objects, where each describes a line range in a file
|
|
* that was changed in the pull request described by {@link branches} or `undefined` if
|
|
* we were unable to compute them.
|
|
*/
|
|
export async function getPullRequestEditedDiffRanges(
|
|
branches: PullRequestBranches,
|
|
logger: Logger,
|
|
): Promise<DiffThunkRange[] | undefined> {
|
|
const fileDiffs = await getFileDiffsWithBasehead(branches, logger);
|
|
if (fileDiffs === undefined) {
|
|
return undefined;
|
|
}
|
|
if (fileDiffs.length >= 300) {
|
|
// The "compare two commits" API returns a maximum of 300 changed files. If
|
|
// we see that many changed files, it is possible that there could be more,
|
|
// with the rest being truncated. In this case, we should not attempt to
|
|
// compute the diff ranges, as the result would be incomplete.
|
|
logger.warning(
|
|
`Cannot retrieve the full diff because there are too many ` +
|
|
`(${fileDiffs.length}) changed files in the pull request.`,
|
|
);
|
|
return undefined;
|
|
}
|
|
const results: DiffThunkRange[] = [];
|
|
for (const filediff of fileDiffs) {
|
|
const diffRanges = getDiffRanges(filediff, logger);
|
|
if (diffRanges === undefined) {
|
|
return undefined;
|
|
}
|
|
results.push(...diffRanges);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Compute and persist the diff ranges for a pull request. This fetches the
|
|
* diff from the GitHub API and writes it to the diff ranges JSON file so that
|
|
* CodeQL can use it for diff-informed analysis.
|
|
*
|
|
* @param branches The base and head branches of the pull request, as returned
|
|
* by `getDiffInformedAnalysisBranches`.
|
|
* @param logger The logger to use.
|
|
*
|
|
* @returns `true` if the diff ranges were successfully computed and persisted,
|
|
* otherwise `false`.
|
|
*/
|
|
export async function computeAndPersistDiffRanges(
|
|
branches: PullRequestBranches,
|
|
logger: Logger,
|
|
): Promise<boolean> {
|
|
logger.info("Computing PR diff ranges...");
|
|
const ranges = await getPullRequestEditedDiffRanges(branches, logger);
|
|
if (ranges === undefined) {
|
|
return false;
|
|
}
|
|
writeDiffRangesJsonFile(logger, ranges);
|
|
const distinctFiles = new Set(ranges.map((r) => r.path)).size;
|
|
logger.info(
|
|
`Persisted ${ranges.length} diff range(s) across ${distinctFiles} file(s).`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Fetch an array of {@link FileDiff} objects using the GitHub API describing the changed files
|
|
* in the pull request with the base and head refs given by {@link branches}.
|
|
*
|
|
* @param branches The pull request branch refs.
|
|
* @param logger The logger to use.
|
|
*
|
|
* @throws Non-HTTP errors that occur while fetching the file diffs.
|
|
*
|
|
* @returns
|
|
* An array of {@link FileDiff} objects describing the changed files in the PR, or
|
|
* `undefined` if we couldn't obtain the changed files from the API.
|
|
*/
|
|
async function getFileDiffsWithBasehead(
|
|
branches: PullRequestBranches,
|
|
logger: Logger,
|
|
): Promise<FileDiff[] | undefined> {
|
|
// Check CODE_SCANNING_REPOSITORY first. If it is empty or not set, fall back
|
|
// to GITHUB_REPOSITORY.
|
|
const repositoryNwo = getRepositoryNwoFromEnv(
|
|
"CODE_SCANNING_REPOSITORY",
|
|
"GITHUB_REPOSITORY",
|
|
);
|
|
const basehead = `${branches.base}...${branches.head}`;
|
|
try {
|
|
const response = await getApiClient().rest.repos.compareCommitsWithBasehead(
|
|
{
|
|
owner: repositoryNwo.owner,
|
|
repo: repositoryNwo.repo,
|
|
basehead,
|
|
per_page: 1,
|
|
},
|
|
);
|
|
logger.debug(
|
|
`Response from compareCommitsWithBasehead(${basehead}):` +
|
|
`\n${JSON.stringify(response, null, 2)}`,
|
|
);
|
|
return response.data.files;
|
|
} catch (error: any) {
|
|
if (error.status) {
|
|
logger.warning(`Error retrieving diff ${basehead}: ${error.message}`);
|
|
logger.debug(
|
|
`Error running compareCommitsWithBasehead(${basehead}):` +
|
|
`\nRequest: ${JSON.stringify(error.request, null, 2)}` +
|
|
`\nError Response: ${JSON.stringify(error.response, null, 2)}`,
|
|
);
|
|
return undefined;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts an array of {@link DiffThunkRange} objects from {@link fileDiff}.
|
|
*
|
|
* @param fileDiff The {@link FileDiff} object describing changes to a particular file.
|
|
* @param logger The logger to use.
|
|
*
|
|
* @returns
|
|
* The {@link DiffThunkRange} objects extracted from {@link fileDiff},
|
|
* or `undefined` if there are none that could be extracted.
|
|
*/
|
|
function getDiffRanges(
|
|
fileDiff: FileDiff,
|
|
logger: Logger,
|
|
): DiffThunkRange[] | undefined {
|
|
if (fileDiff.patch === undefined) {
|
|
if (fileDiff.changes === 0) {
|
|
// There are situations where a changed file legitimately has no diff.
|
|
// For example, the file may be a binary file, or that the file may have
|
|
// been renamed with no changes to its contents. In these cases, the
|
|
// file would be reported as having 0 changes, and we can return an empty
|
|
// array to indicate no diff range in this file.
|
|
return [];
|
|
}
|
|
// If a file is reported to have nonzero changes but no patch, that may be
|
|
// due to the file diff being too large. In this case, we should fall back
|
|
// to a special diff range that covers the entire file.
|
|
return [
|
|
{
|
|
path: fileDiff.filename,
|
|
startLine: 0,
|
|
endLine: 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
// The 1-based file line number of the current line
|
|
let currentLine = 0;
|
|
// The 1-based file line number that starts the current range of added lines
|
|
let additionRangeStartLine: number | undefined = undefined;
|
|
const diffRanges: DiffThunkRange[] = [];
|
|
|
|
const diffLines = fileDiff.patch.split("\n");
|
|
// Adding a fake context line at the end ensures that the following loop will
|
|
// always terminate the last range of added lines.
|
|
diffLines.push(" ");
|
|
|
|
for (const diffLine of diffLines) {
|
|
if (diffLine.startsWith("-")) {
|
|
// Ignore deletions completely -- we do not even want to consider them when
|
|
// calculating consecutive ranges of added lines.
|
|
continue;
|
|
}
|
|
if (diffLine.startsWith("+")) {
|
|
if (additionRangeStartLine === undefined) {
|
|
additionRangeStartLine = currentLine;
|
|
}
|
|
currentLine++;
|
|
continue;
|
|
}
|
|
if (additionRangeStartLine !== undefined) {
|
|
// Any line that does not start with a "+" or "-" terminates the current
|
|
// range of added lines.
|
|
diffRanges.push({
|
|
path: fileDiff.filename,
|
|
startLine: additionRangeStartLine,
|
|
endLine: currentLine - 1,
|
|
});
|
|
additionRangeStartLine = undefined;
|
|
}
|
|
if (diffLine.startsWith("@@ ")) {
|
|
// A new hunk header line resets the current line number.
|
|
const match = diffLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
if (match === null) {
|
|
logger.warning(
|
|
`Cannot parse diff hunk header for ${fileDiff.filename}: ${diffLine}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
currentLine = parseInt(match[1], 10);
|
|
continue;
|
|
}
|
|
if (diffLine.startsWith(" ")) {
|
|
// An unchanged context line advances the current line number.
|
|
currentLine++;
|
|
continue;
|
|
}
|
|
}
|
|
return diffRanges;
|
|
}
|
|
|
|
export const exportedForTesting = {
|
|
getDiffRanges,
|
|
};
|