Skip to content

Commit e3c191f

Browse files
committed
Diff-informed PR analysis
1 parent 78cfb2f commit e3c191f

File tree

3 files changed

+221
-2
lines changed

3 files changed

+221
-2
lines changed

src/analyze-action.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "path";
33
import { performance } from "perf_hooks";
44

55
import * as core from "@actions/core";
6+
import * as github from "@actions/github";
67

78
import * as actionsUtil from "./actions-util";
89
import {
@@ -12,6 +13,7 @@ import {
1213
runCleanup,
1314
runFinalize,
1415
runQueries,
16+
setupDiffInformedQueryRun,
1517
warnIfGoInstalledAfterInit,
1618
} from "./analyze";
1719
import { getApiDetails, getGitHubVersion } from "./api-client";
@@ -260,6 +262,18 @@ async function run() {
260262
logger,
261263
);
262264

265+
const pull_request = github.context.payload.pull_request;
266+
const diffRangePackDir =
267+
pull_request === undefined
268+
? undefined
269+
: await setupDiffInformedQueryRun(
270+
pull_request.base.ref as string,
271+
pull_request.head.ref as string,
272+
codeql,
273+
logger,
274+
features,
275+
);
276+
263277
await warnIfGoInstalledAfterInit(config, logger);
264278
await runAutobuildIfLegacyGoWorkflow(config, logger);
265279

@@ -278,6 +292,7 @@ async function run() {
278292
memory,
279293
util.getAddSnippetsFlag(actionsUtil.getRequiredInput("add-snippets")),
280294
threads,
295+
diffRangePackDir,
281296
actionsUtil.getOptionalInput("category"),
282297
config,
283298
logger,

src/analyze.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ test("status report fields", async (t) => {
101101
addSnippetsFlag,
102102
threadsFlag,
103103
undefined,
104+
undefined,
104105
config,
105106
getRunnerLogger(true),
106107
createFeatures([Feature.QaTelemetryEnabled]),

src/analyze.ts

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { safeWhich } from "@chrisgavin/safe-which";
66
import del from "del";
77
import * as yaml from "js-yaml";
88

9+
import * as actionsUtil from "./actions-util";
910
import { setupCppAutobuild } from "./autobuild";
1011
import {
1112
CODEQL_VERSION_ANALYSIS_SUMMARY_V2,
@@ -234,23 +235,225 @@ async function finalizeDatabaseCreation(
234235
};
235236
}
236237

238+
/**
239+
* Set up the diff-informed analysis feature.
240+
*
241+
* @param baseRef The base branch name, used for calculating the diff range.
242+
* @param headRef The head branch name, used for calculating the diff range.
243+
* @param codeql
244+
* @param logger
245+
* @param features
246+
* @returns Absolute path to the directory containing the extension pack for
247+
* the diff range information, or `undefined` if the feature is disabled.
248+
*/
249+
export async function setupDiffInformedQueryRun(
250+
baseRef: string,
251+
headRef: string,
252+
codeql: CodeQL,
253+
logger: Logger,
254+
features: FeatureEnablement,
255+
): Promise<string | undefined> {
256+
if (!(await features.getValue(Feature.DiffInformedQueries, codeql))) {
257+
return undefined;
258+
}
259+
logger.startGroup("Generating diff range extension pack");
260+
const diffRanges = await getPullRequestEditedDiffRanges(
261+
baseRef,
262+
headRef,
263+
logger,
264+
);
265+
const diffRangePackDir = writeDiffRangeDataExtensionPack(logger, diffRanges);
266+
logger.endGroup();
267+
return diffRangePackDir;
268+
}
269+
270+
/**
271+
* Return the file line ranges that were added or modified in the pull request.
272+
*
273+
* @param baseRef The base branch name, used for calculating the diff range.
274+
* @param headRef The head branch name, used for calculating the diff range.
275+
* @param logger
276+
* @returns An array of tuples, where each tuple contains the absolute path of a
277+
* file, the start line and the end line (both 1-based and inclusive) of an
278+
* added or modified range in that file. Returns `undefined` if the action was
279+
* not triggered by a pull request or if there was an error.
280+
*/
281+
async function getPullRequestEditedDiffRanges(
282+
baseRef: string,
283+
headRef: string,
284+
logger: Logger,
285+
): Promise<Array<[string, number, number]> | undefined> {
286+
const checkoutPath = actionsUtil.getOptionalInput("checkout_path");
287+
if (checkoutPath === undefined) {
288+
return undefined;
289+
}
290+
291+
// To compute the merge bases between the base branch and the PR topic branch,
292+
// we need to fetch the commit graph from the branch heads to those merge
293+
// babes. The following 4-step procedure does so while limiting the amount of
294+
// history fetched.
295+
296+
// Step 1: Deepen from the PR merge commit to the base branch head and the PR
297+
// topic branch head, so that the PR merge commit is no longer considered a
298+
// grafted commit.
299+
await actionsUtil.deepenGitHistory();
300+
// Step 2: Fetch the base branch shallow history. This step ensures that the
301+
// base branch name is present in the local repository. Normally the base
302+
// branch name would be added by Step 4. However, if the base branch head is
303+
// an ancestor of the PR topic branch head, Step 4 would fail without doing
304+
// anything, so we need to fetch the base branch explicitly.
305+
await actionsUtil.gitFetch(baseRef, ["--depth=1"]);
306+
// Step 3: Fetch the PR topic branch history, stopping when we reach commits
307+
// that are reachable from the base branch head.
308+
await actionsUtil.gitFetch(headRef, [`--shallow-exclude=${baseRef}`]);
309+
// Step 4: Fetch the base branch history, stopping when we reach commits that
310+
// are reachable from the PR topic branch head.
311+
await actionsUtil.gitFetch(baseRef, [`--shallow-exclude=${headRef}`]);
312+
// Step 5: Deepen the history so that we have the merge bases between the base
313+
// branch and the PR topic branch.
314+
await actionsUtil.deepenGitHistory();
315+
316+
// To compute the exact same diff as GitHub would compute for the PR, we need
317+
// to use the same merge base as GitHub. That is easy to do if there is only
318+
// one merge base, which is by far the most common case. If there are multiple
319+
// merge bases, we stop without producing a diff range.
320+
const mergeBases = await actionsUtil.getAllGitMergeBases([baseRef, headRef]);
321+
logger.info(`Merge bases: ${mergeBases.join(", ")}`);
322+
if (mergeBases.length !== 1) {
323+
return undefined;
324+
}
325+
326+
const diffHunkHeaders = await actionsUtil.getGitDiffHunkHeaders(
327+
mergeBases[0],
328+
headRef,
329+
);
330+
if (diffHunkHeaders === undefined) {
331+
return undefined;
332+
}
333+
334+
const results = new Array<[string, number, number]>();
335+
336+
let changedFile = "";
337+
for (const line of diffHunkHeaders) {
338+
if (line.startsWith("+++ ")) {
339+
const filePath = actionsUtil.decodeGitFilePath(line.substring(4));
340+
if (filePath.startsWith("b/")) {
341+
changedFile = filePath.substring(2);
342+
} else {
343+
// Not an actual file path; probably /dev/null
344+
changedFile = "";
345+
}
346+
continue;
347+
}
348+
if (line.startsWith("@@ ")) {
349+
if (changedFile === "") continue;
350+
351+
const match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
352+
if (match === null) {
353+
logger.info(
354+
`Failed to parse diff hunk header line: ${line}. Skipping.`,
355+
);
356+
continue;
357+
}
358+
const startLine = parseInt(match[1], 10);
359+
const endLine = startLine + (parseInt(match[2], 10) || 1) - 1;
360+
results.push([path.join(checkoutPath, changedFile), startLine, endLine]);
361+
}
362+
}
363+
return results;
364+
}
365+
366+
/**
367+
* Create an extension pack in the temporary directory that contains the file
368+
* line ranges that were added or modified in the pull request.
369+
*
370+
* @param logger
371+
* @param ranges The file line ranges, as returned by
372+
* `getPullRequestEditedDiffRanges`.
373+
* @returns The absolute path of the directory containing the extension pack, or
374+
* `undefined` if no extension pack was created.
375+
*/
376+
function writeDiffRangeDataExtensionPack(
377+
logger: Logger,
378+
ranges: Array<[string, number, number]> | undefined,
379+
): string | undefined {
380+
if (ranges === undefined) {
381+
return undefined;
382+
}
383+
384+
const diffRangeDir = path.join(
385+
actionsUtil.getTemporaryDirectory(),
386+
"pr-diff-range",
387+
);
388+
fs.mkdirSync(diffRangeDir);
389+
fs.writeFileSync(
390+
path.join(diffRangeDir, "qlpack.yml"),
391+
`
392+
name: codeql-action/pr-diff-range
393+
version: 0.0.0
394+
library: true
395+
extensionTargets:
396+
codeql/util: '*'
397+
dataExtensions:
398+
- pr-diff-range.yml
399+
`,
400+
);
401+
402+
const header = `
403+
extensions:
404+
- addsTo:
405+
pack: codeql/util
406+
extensible: restrictAlertsTo
407+
data:
408+
`;
409+
410+
let data = ranges
411+
.map((range) => {
412+
return ` - ["${range[0]}", ${range[1]}, ${range[2]}]\n`;
413+
})
414+
.join("");
415+
if (!data) {
416+
// Ensure that the data extension is not empty, so that a pull request with
417+
// no edited lines would exclude (instead of accepting) all alerts.
418+
data = ' - ["", 0, 0]\n';
419+
}
420+
421+
const extensionContents = header + data;
422+
const extensionFilePath = path.join(diffRangeDir, "pr-diff-range.yml");
423+
fs.writeFileSync(extensionFilePath, extensionContents);
424+
logger.info(
425+
`Wrote pr-diff-range extension pack to ${extensionFilePath}:\n${extensionContents}`,
426+
);
427+
428+
return diffRangeDir;
429+
}
430+
237431
// Runs queries and creates sarif files in the given folder
238432
export async function runQueries(
239433
sarifFolder: string,
240434
memoryFlag: string,
241435
addSnippetsFlag: string,
242436
threadsFlag: string,
437+
diffRangePackDir: string | undefined,
243438
automationDetailsId: string | undefined,
244439
config: configUtils.Config,
245440
logger: Logger,
246441
features: FeatureEnablement,
247442
): Promise<QueriesStatusReport> {
248443
const statusReport: QueriesStatusReport = {};
249444

250-
const sarifRunPropertyFlag = undefined;
445+
const dataExtensionFlags = diffRangePackDir
446+
? [
447+
`--additional-packs=${diffRangePackDir}`,
448+
"--extension-packs=codeql-action/pr-diff-range",
449+
]
450+
: [];
451+
const sarifRunPropertyFlag = diffRangePackDir
452+
? "--sarif-run-property=incrementalMode=diff-informed"
453+
: undefined;
251454

252455
const codeql = await getCodeQL(config.codeQLCmd);
253-
const queryFlags = [memoryFlag, threadsFlag];
456+
const queryFlags = [memoryFlag, threadsFlag, ...dataExtensionFlags];
254457

255458
for (const language of config.languages) {
256459
try {

0 commit comments

Comments
 (0)