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 { 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 { 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 { 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 { 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 { // 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, };