@@ -6,6 +6,7 @@ import { safeWhich } from "@chrisgavin/safe-which";
6
6
import del from "del" ;
7
7
import * as yaml from "js-yaml" ;
8
8
9
+ import * as actionsUtil from "./actions-util" ;
9
10
import { setupCppAutobuild } from "./autobuild" ;
10
11
import {
11
12
CODEQL_VERSION_ANALYSIS_SUMMARY_V2 ,
@@ -234,23 +235,225 @@ async function finalizeDatabaseCreation(
234
235
} ;
235
236
}
236
237
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
+
237
431
// Runs queries and creates sarif files in the given folder
238
432
export async function runQueries (
239
433
sarifFolder : string ,
240
434
memoryFlag : string ,
241
435
addSnippetsFlag : string ,
242
436
threadsFlag : string ,
437
+ diffRangePackDir : string | undefined ,
243
438
automationDetailsId : string | undefined ,
244
439
config : configUtils . Config ,
245
440
logger : Logger ,
246
441
features : FeatureEnablement ,
247
442
) : Promise < QueriesStatusReport > {
248
443
const statusReport : QueriesStatusReport = { } ;
249
444
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 ;
251
454
252
455
const codeql = await getCodeQL ( config . codeQLCmd ) ;
253
- const queryFlags = [ memoryFlag , threadsFlag ] ;
456
+ const queryFlags = [ memoryFlag , threadsFlag , ... dataExtensionFlags ] ;
254
457
255
458
for ( const language of config . languages ) {
256
459
try {
0 commit comments