Compare commits

..

5 Commits

Author SHA1 Message Date
Michael B. Gale
49cca89390 Merge branch 'main' into mbg/clean-up-diff-informed-utils 2026-11-09 14:05:27 +00:00
Michael B. Gale
c29d405e42 Move getDiffRangesJsonFilePath to diff-informed/index.ts 2026-11-04 15:00:19 +00:00
Michael B. Gale
8c16574468 Make jsonFilePath an explicit input to functions in diff-informed/index.ts 2026-11-04 14:34:23 +00:00
Michael B. Gale
3a4f195d36 Improve JSDocs in diff-informed/index.ts 2026-11-04 13:24:10 +00:00
Michael B. Gale
2465f77e2e Move diff-informed utils to subfolder 2026-11-04 13:24:10 +00:00
44 changed files with 1018 additions and 1464 deletions

View File

@@ -1,41 +0,0 @@
name: "Post comment"
description: "Create or update a sticky PR comment identified by a caller-provided marker."
inputs:
marker:
description: >
Non-empty plain-text identifier for the comment. Use a unique, stable value
for each distinct comment; it is automatically wrapped in a hidden HTML comment.
required: true
body:
description: "Full comment markdown."
required: true
update-only:
description: Do nothing if no comment with the specified marker exists.
required: false
default: "false"
issue-id:
description: >
The issue or pull request number to post/update the comment on.
Defaults to the current pull request if not provided.
required: false
token:
description: "The GitHub token to authenticate with."
required: true
runs:
using: composite
steps:
- name: Post comment
shell: bash
env:
BODY: ${{ inputs.body }}
MARKER: ${{ inputs.marker }}
UPDATE_ONLY: ${{ case(inputs.update-only == 'true', '--update-only', '') }}
GH_TOKEN: ${{ inputs.token }}
ISSUE_ID: ${{ inputs.issue-id || github.event.pull_request.number }}
run: |
npx tsx ./pr-checks/post-comment.ts \
--body "$BODY" \
--marker "$MARKER" \
$UPDATE_ONLY \
--issue-id "$ISSUE_ID" \
--repository "$GITHUB_REPOSITORY"

View File

@@ -36,14 +36,7 @@ runs:
set -e # Fail this Action if `gh release list` fails.
if [[ "$VERSION" == "nightly" || "$VERSION" == "nightly-latest" ]]; then
if [[ "$USE_ALL_PLATFORM_BUNDLE" == "true" ]]; then
# With `tools: nightly`, the Action downloads the bundle for the runner's platform, so look
# up the latest nightly release here to get its all-platform bundle instead.
tag=$(gh release list --repo dsp-testing/codeql-cli-nightlies --limit 1 --json tagName --jq '.[0].tagName')
echo "tools-url=https://github.com/dsp-testing/codeql-cli-nightlies/releases/download/$tag/codeql-bundle.tar.gz" >> "$GITHUB_OUTPUT"
else
echo "tools-url=nightly" >> "$GITHUB_OUTPUT"
fi
echo "tools-url=nightly" >> "$GITHUB_OUTPUT"
exit 0
elif [[ "$VERSION" == "linked" ]]; then
echo "tools-url=linked" >> "$GITHUB_OUTPUT"

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go
@@ -92,18 +92,6 @@ jobs:
# Swift is not supported on Ubuntu so we manually exclude it from the list here
languages: cpp,csharp,go,java,javascript,python,ruby
tools: ${{ steps.prepare-test.outputs.tools-url }}
- name: Check that the bundle contains tools for all platforms
env:
CODEQL_PATH: ${{ steps.init.outputs.codeql-path }}
run: |
# Platform-specific bundles only contain the tools for their own platform.
tools_dir="$(dirname "$CODEQL_PATH")/tools"
for platform in linux64 osx64 win64; do
if [[ ! -d "$tools_dir/$platform" ]]; then
echo "::error::Expected an all-platform CodeQL bundle, but '$tools_dir' does not contain a '$platform' directory."
exit 1
fi
done
- name: Build code
run: ./build.sh
- uses: ./../action/analyze

View File

@@ -67,7 +67,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -61,7 +61,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Prepare test

View File

@@ -63,7 +63,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Java
uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version: ${{ inputs.java-version || '17' }}
distribution: temurin

View File

@@ -63,7 +63,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Java
uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
java-version: ${{ inputs.java-version || '17' }}
distribution: temurin

View File

@@ -67,7 +67,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -69,7 +69,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -67,7 +67,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -67,7 +67,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -101,7 +101,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -69,7 +69,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -77,7 +77,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -71,7 +71,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -69,7 +69,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -67,7 +67,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -74,7 +74,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -68,7 +68,7 @@ jobs:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version || '9.x' }}
- name: Install Go

View File

@@ -63,7 +63,7 @@ jobs:
with:
go-version: ^1.13.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: "9.x"
- name: Assert best-effort artifact scan completed

View File

@@ -59,7 +59,7 @@ jobs:
with:
go-version: ^1.13.1
- name: Install .NET
uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: "9.x"
- name: Assert best-effort artifact scan completed

View File

@@ -31,6 +31,7 @@ on:
- "PR Check - C/C\\+\\+: autoinstalling dependencies is skipped (macOS)"
- "PR Check - C/C\\+\\+: disabling autoinstalling dependencies (Linux)"
- "PR Check - Clean up database cluster directory"
- "PR Check - CodeQL Bundle All"
- "PR Check - Config export"
- "PR Check - Config input"
- "PR Check - Custom source root"

View File

@@ -0,0 +1,62 @@
name: "PR Check - CodeQL Bundle All"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GO111MODULE: auto
on:
push:
branches:
- main
- releases/v*
pull_request:
merge_group:
types: [checks_requested]
schedule:
- cron: "0 5 * * *"
workflow_dispatch:
concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' || false }}
group: ${{ github.workflow }}-${{ github.ref }}
defaults:
run:
shell: bash
jobs:
test-codeql-bundle-all:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-26.04
version: nightly-latest
name: "CodeQL Bundle All"
if: github.triggering_actor != 'dependabot[bot]'
permissions:
contents: read
security-events: read
timeout-minutes: 45
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare test
id: prepare-test
uses: ./.github/actions/prepare-test
with:
version: ${{ matrix.version }}
use-all-platform-bundle: true
- name: Install .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: "9.x"
- id: init
uses: ./../action/init
with:
# We manually exclude Swift from the languages list here, as it is not supported on Ubuntu
languages: cpp,csharp,go,java,javascript,python,ruby
tools: ${{ steps.prepare-test.outputs.tools-url }}
- name: Build code
run: ./build.sh
- uses: ./../action/analyze
env:
CODEQL_ACTION_TEST_MODE: true

1511
lib/entry-points.js generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,14 +8,3 @@ export function getApiClient(token: string): ApiClient {
const opts = githubUtils.getOctokitOptions(token);
return new githubUtils.GitHub(opts);
}
/** Resolves the GitHub API token to use, from `GH_TOKEN` or `GITHUB_TOKEN`. */
export function resolveToken(env: NodeJS.ProcessEnv): string {
const token = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim();
if (!token) {
throw new Error(
"Missing authentication token. Set GH_TOKEN or GITHUB_TOKEN.",
);
}
return token;
}

View File

@@ -17,18 +17,6 @@ steps:
# Swift is not supported on Ubuntu so we manually exclude it from the list here
languages: cpp,csharp,go,java,javascript,python,ruby
tools: ${{ steps.prepare-test.outputs.tools-url }}
- name: Check that the bundle contains tools for all platforms
env:
CODEQL_PATH: ${{ steps.init.outputs.codeql-path }}
run: |
# Platform-specific bundles only contain the tools for their own platform.
tools_dir="$(dirname "$CODEQL_PATH")/tools"
for platform in linux64 osx64 win64; do
if [[ ! -d "$tools_dir/$platform" ]]; then
echo "::error::Expected an all-platform CodeQL bundle, but '$tools_dir' does not contain a '$platform' directory."
exit 1
fi
done
- name: Build code
run: ./build.sh
- uses: ./../action/analyze

View File

@@ -1,344 +0,0 @@
#!/usr/bin/env npx tsx
/*
Tests for post-comment.ts.
*/
import * as assert from "node:assert/strict";
import { describe, it } from "node:test";
import { type ApiClient, resolveToken } from "./api-client";
import {
findExistingComment,
type Options,
parseOptions,
setComment,
} from "./post-comment";
/** A baseline set of valid CLI flags, as an array, for use in parseOptions tests. */
const validFlags = [
"--body",
"hello",
"--marker",
"marker",
"--issue-id",
"42",
"--repository",
"github/codeql-action",
];
/** Returns a copy of `validFlags` with the value following `flag` replaced by `value`. */
function withFlag(flag: string, value: string): string[] {
const flags = [...validFlags];
const index = flags.indexOf(flag);
flags[index + 1] = value;
return flags;
}
/** Returns a copy of `validFlags` with `flag` (and its value) removed entirely. */
function withoutFlag(flag: string): string[] {
const flags = [...validFlags];
const index = flags.indexOf(flag);
flags.splice(index, 2);
return flags;
}
describe("parseOptions", async () => {
await it("parses valid flags", () => {
const options = parseOptions(validFlags);
assert.deepEqual(options, {
body: "hello",
marker: "marker",
updateOnly: false,
issueId: 42,
repository: { owner: "github", repo: "codeql-action" },
});
});
await it("enables update-only mode when --update-only is supplied", () => {
const options = parseOptions([...validFlags, "--update-only"]);
assert.equal(options.updateOnly, true);
});
await it("rejects a missing --body", () => {
assert.throws(
() => parseOptions(withoutFlag("--body")),
/Missing required flag --body/,
);
});
await it("rejects an empty --body", () => {
assert.throws(
() => parseOptions(withFlag("--body", "")),
/The value for --body cannot be empty/,
);
});
await it("rejects a whitespace-only --body", () => {
assert.throws(
() => parseOptions(withFlag("--body", " \t\n")),
/The value for --body cannot be empty/,
);
});
await it("rejects a missing --marker", () => {
assert.throws(
() => parseOptions(withoutFlag("--marker")),
/Missing required flag --marker/,
);
});
await it("rejects an empty --marker", () => {
assert.throws(
() => parseOptions(withFlag("--marker", "")),
/The value for --marker cannot be empty/,
);
});
await it("rejects a whitespace-only --marker", () => {
assert.throws(
() => parseOptions(withFlag("--marker", " \t\n")),
/The value for --marker cannot be empty/,
);
});
await it("rejects a missing --repository", () => {
assert.throws(
() => parseOptions(withoutFlag("--repository")),
/Missing required flag --repository/,
);
});
await it("rejects an empty --repository", () => {
assert.throws(
() => parseOptions(withFlag("--repository", "")),
/The value for --repository cannot be empty/,
);
});
await it("rejects a whitespace-only --repository", () => {
assert.throws(
() => parseOptions(withFlag("--repository", " \t\n")),
/The value for --repository cannot be empty/,
);
});
await it("rejects an invalid --repository value", () => {
assert.throws(
() => parseOptions(withFlag("--repository", "github/codeql/action")),
/Invalid --repository value/,
);
});
});
describe("resolveToken", async () => {
await it("reads the token from GH_TOKEN", () => {
assert.equal(
resolveToken({ GH_TOKEN: "gh-token", GITHUB_TOKEN: "gha-token" }),
"gh-token",
);
});
await it("falls back to GITHUB_TOKEN", () => {
assert.equal(resolveToken({ GITHUB_TOKEN: "gha-token" }), "gha-token");
});
await it("throws when no token is set", () => {
assert.throws(() => resolveToken({}), /Missing authentication token/);
});
});
/** A minimal fake of the subset of `ApiClient` used by post-comment.ts. */
function fakeClient(existingComments: Array<{ id: number; body?: string }>) {
const comments = existingComments.map((comment) => ({ ...comment }));
let nextId =
comments.reduce((maxId, comment) => Math.max(maxId, comment.id), 0) + 1;
const client = {
paginate: async () => comments,
rest: {
issues: {
listComments: () => {},
createComment: async ({ body }: { body: string }) => {
comments.push({ id: nextId++, body });
},
updateComment: async ({
comment_id,
body,
}: {
comment_id: number;
body: string;
}) => {
const comment = comments.find(({ id }) => id === comment_id);
assert.ok(comment, `Comment ${comment_id} does not exist.`);
comment.body = body;
},
},
},
};
return { client: client as unknown as ApiClient, comments };
}
describe("findExistingComment", async () => {
await it("finds the id of the first comment containing the marker", async () => {
const { client } = fakeClient([
{ id: 1, body: "unrelated" },
{ id: 2, body: "<!-- marker -->\nhello" },
{ id: 3, body: "<!-- marker -->\nanother" },
]);
const id = await findExistingComment(
client,
{ owner: "owner", repo: "repo" },
1,
"marker",
);
assert.equal(id, 3);
});
await it("returns undefined when no comment matches", async () => {
const { client } = fakeClient([{ id: 1, body: "unrelated" }]);
const id = await findExistingComment(
client,
{ owner: "owner", repo: "repo" },
1,
"marker",
);
assert.equal(id, undefined);
});
});
/** A baseline set of `Options`, for use in `setComment` tests. */
const baseOptions: Options = {
body: "hello",
marker: "marker",
updateOnly: false,
issueId: 42,
repository: { owner: "owner", repo: "repo" },
};
function assertCommentContent(
comment: { body?: string },
marker: string,
body: string,
): void {
assert.ok(comment.body?.startsWith(`<!-- ${marker} -->`));
assert.ok(comment.body?.endsWith(body));
}
describe("setComment", async () => {
await it("does not create a comment in update-only mode when none exists", async () => {
const { client, comments } = fakeClient([]);
await setComment(client, { ...baseOptions, updateOnly: true });
// Resolution does not introduce a warning that was never posted.
assert.deepEqual(comments, []);
});
await it("leaves unrelated comments unchanged in update-only mode", async () => {
const originalComments = [{ id: 7, body: "unrelated" }];
const { client, comments } = fakeClient(originalComments);
await setComment(client, { ...baseOptions, updateOnly: true });
// Unrelated comments neither trigger resolution nor get overwritten.
assert.deepEqual(comments, originalComments);
});
await it("uses the supplied marker without affecting other comments", async () => {
const marker = "custom-warning";
const { client, comments } = fakeClient([
{ id: 1, body: "<!-- marker -->\nunrelated warning" },
{ id: 2, body: "<!-- custom-warning -->\nold warning" },
]);
const options = parseOptions(withFlag("--marker", marker));
await setComment(client, options);
// Only the comment with the supplied marker changes.
assert.equal(comments.length, 2);
assert.deepEqual(comments[0], {
id: 1,
body: "<!-- marker -->\nunrelated warning",
});
assert.equal(comments[1].id, 2);
assertCommentContent(comments[1], marker, options.body);
});
await it("creates a new comment with the marker prepended", async () => {
const { client, comments } = fakeClient([]);
// No comment has the marker before posting.
assert.equal(comments.length, 0);
assert.ok(
!comments.some(({ body }) => body?.startsWith("<!-- marker -->")),
);
await setComment(client, baseOptions);
// Posting adds exactly one comment with the marker before the body.
assert.equal(comments.length, 1);
assertCommentContent(comments[0], baseOptions.marker, baseOptions.body);
});
await it("creates a new comment when no existing comment matches", async () => {
const originalComments = [
{ id: 7, body: "unrelated" },
{ id: 2, body: "another unrelated comment" },
];
const { client, comments } = fakeClient(originalComments);
// Existing comments do not match the marker.
assert.ok(
!comments.some(({ body }) => body?.startsWith("<!-- marker -->")),
);
await setComment(client, baseOptions);
// A new comment is added without changing unrelated comments.
assert.equal(comments.length, originalComments.length + 1);
assert.deepEqual(
comments.slice(0, originalComments.length),
originalComments,
);
const markedComments = comments.filter(({ body }) =>
body?.startsWith("<!-- marker -->"),
);
// Exactly one comment contains the expected marker and body.
assert.equal(markedComments.length, 1);
assertCommentContent(
markedComments[0],
baseOptions.marker,
baseOptions.body,
);
// The new comment's ID does not collide with existing IDs.
assert.equal(new Set(comments.map(({ id }) => id)).size, comments.length);
});
await it("updates the existing comment when one is found", async () => {
const { client, comments } = fakeClient([
{ id: 7, body: "<!-- marker -->\nold" },
]);
await setComment(client, baseOptions);
// Updating preserves the comment's ID without creating a duplicate.
assert.equal(comments.length, 1);
assert.equal(comments[0].id, 7);
assertCommentContent(comments[0], baseOptions.marker, baseOptions.body);
});
await it("replaces a warning with the resolved state using the same marker", async () => {
const { client, comments } = fakeClient([
{ id: 9, body: "<!-- marker -->\nold" },
]);
const body =
"`CHANGELOG.md` is not modified in this PR. No changelog action is needed.";
await setComment(client, { ...baseOptions, body, updateOnly: true });
// The resolved state replaces the warning on the same marked comment.
assert.equal(comments.length, 1);
assert.equal(comments[0].id, 9);
assertCommentContent(comments[0], baseOptions.marker, body);
});
});

View File

@@ -1,180 +0,0 @@
#!/usr/bin/env npx tsx
/*
Creates or updates a pull-request comment identified by a marker.
The marker is supplied via `--marker` and must be a non-empty plain-text identifier.
The CLI wraps the marker in a hidden HTML comment, prepends it to `--body`,
and uses it to find the comment on later runs.
Supply `--update-only` to suppress creation of comment; omit it
to update or create comments.
Example:
```sh
GH_TOKEN=<token> npx tsx ./pr-checks/post-comment.ts \
--body "The changelog needs updating." \
--marker "my-bot" \
--issue-id 123 \
--repository github/codeql-action
```
*/
import assert from "node:assert/strict";
import { parseArgs } from "node:util";
import { parseRepositoryNwo, type RepositoryNwo } from "../src/repository";
import { type ApiClient, getApiClient, resolveToken } from "./api-client";
/** Represents the command-line options. */
export interface Options {
/** The full comment body. */
body: string;
/** Plain-text identifier wrapped in a hidden HTML comment. */
marker: string;
/** Whether to only update a comment if a matching marker exists. */
updateOnly: boolean;
/** The issue/pull request number to post/update the comment on. */
issueId: number;
/** The repository to operate on. */
repository: RepositoryNwo;
}
function hiddenMarker(marker: string): string {
return `<!-- ${marker} -->`;
}
/** Parses CLI arguments supplied by the composite action into `Options`. */
export function parseOptions(argv?: string[]): Options {
const { values } = parseArgs({
args: argv,
options: {
body: { type: "string" },
marker: { type: "string" },
"update-only": { type: "boolean", default: false },
"issue-id": { type: "string" },
repository: { type: "string" },
},
strict: true,
});
assert(values.body !== undefined, "Missing required flag --body.");
assert(values.body.trim() !== "", "The value for --body cannot be empty.");
assert(values.marker !== undefined, "Missing required flag --marker.");
assert(
values.marker.trim() !== "",
"The value for --marker cannot be empty.",
);
assert(
values.repository !== undefined,
"Missing required flag --repository.",
);
assert(
values.repository.trim() !== "",
"The value for --repository cannot be empty.",
);
assert(
/^[\w.-]+\/[\w.-]+$/.test(values.repository),
"Invalid --repository value.",
);
return {
body: values.body,
marker: values.marker,
updateOnly: values["update-only"],
issueId: Number(values["issue-id"]),
repository: parseRepositoryNwo(values.repository),
};
}
/**
* Finds the first comment on the given issue/pull request whose body starts with `marker`, if any.
*/
export async function findExistingComment(
client: ApiClient,
repository: RepositoryNwo,
issueNumber: number,
marker: string,
): Promise<number | undefined> {
const formattedMarker = hiddenMarker(marker);
const comments = await client.paginate(client.rest.issues.listComments, {
...repository,
issue_number: issueNumber,
per_page: 100,
});
const markedComments = comments.filter((comment) =>
comment.body?.startsWith(formattedMarker),
);
if (markedComments.length > 1) {
console.warn(
`Found ${markedComments.length} comments with marker "${formattedMarker}". ` +
"Updating the first one and leaving the others unchanged. " +
"Use a unique marker for each distinct managed comment to avoid this situation.",
);
}
return markedComments.at(-1)?.id;
}
/**
* Creates or updates a marked comment using `client` to talk to the GitHub API.
*/
export async function setComment(
client: ApiClient,
options: Options,
): Promise<void> {
const { issueId, marker, repository } = options;
const body = `${hiddenMarker(marker)}\n${options.body}`;
const existingCommentId = await findExistingComment(
client,
repository,
issueId,
marker,
);
if (existingCommentId === undefined) {
if (options.updateOnly) {
console.info(
"No existing comment found; skipping creation because --update-only was specified.",
);
return;
}
console.info("No existing comment found; creating a new comment.");
await client.rest.issues.createComment({
...repository,
issue_number: issueId,
body,
});
} else {
console.info(`Updating existing comment ${existingCommentId}.`);
await client.rest.issues.updateComment({
...repository,
comment_id: existingCommentId,
body,
});
}
}
async function main(): Promise<number> {
const options = parseOptions();
const token = resolveToken(process.env);
const client = getApiClient(token);
await setComment(client, options);
return 0;
}
async function run(): Promise<void> {
try {
process.exit(await main());
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
if (import.meta.main) {
void run();
}

View File

@@ -285,8 +285,8 @@ const languageSetups: LanguageSetups = {
name: "Install Java",
uses: pinnedUses(
"actions/setup-java",
"77dd5b2ce225ea11469928ac317850245a74ae78",
"v6.2.0",
"b843ebf9b913146b38da2eaa7aeddf76074ae1ad",
"v6.1.0",
),
with: {
"java-version": `\${{ inputs.java-version || '${defaultLanguageVersions.java}' }}`,
@@ -320,8 +320,8 @@ const languageSetups: LanguageSetups = {
name: "Install .NET",
uses: pinnedUses(
"actions/setup-dotnet",
"0f4a354dd5547a28c70b7853dbd95d5b67886e73",
"v6.1.0",
"a98b56852c35b8e3190ac28c8c2271da59106c68",
"v6.0.0",
),
with: {
"dotnet-version": `\${{ inputs.dotnet-version || '${defaultLanguageVersions.csharp}' }}`,

View File

@@ -78,12 +78,6 @@ export function getTemporaryDirectory(env: ReadOnlyEnv = getEnv()): string {
);
}
const PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json";
export function getDiffRangesJsonFilePath(env: Env = getEnv()): string {
return path.join(getTemporaryDirectory(env), PR_DIFF_RANGE_JSON_FILENAME);
}
export function getActionVersion(): string {
return __CODEQL_ACTION_VERSION__;
}

View File

@@ -19,9 +19,10 @@ import {
} from "./dependency-caching";
import { addDiagnostic, makeDiagnostic } from "./diagnostics";
import {
DiffThunkRange,
type DiffThunkRange,
getDiffRangesJsonFilePath,
readDiffRangesJsonFile,
} from "./diff-informed-analysis-utils";
} from "./diff-informed";
import { EnvVar } from "./environment";
import { FeatureEnablement, Feature } from "./feature-flags";
import { getGitRoot } from "./git-utils";
@@ -295,7 +296,10 @@ export async function setupDiffInformedQueryRun(
return await withGroupAsync(
"Generating diff range extension pack",
async () => {
const diffRanges = readDiffRangesJsonFile(logger);
const diffRanges = readDiffRangesJsonFile(
logger,
getDiffRangesJsonFilePath(),
);
if (diffRanges === undefined) {
logger.info(
"No precomputed diff ranges found; skipping diff-informed analysis stage.",

View File

@@ -15,6 +15,7 @@ import { createStubCodeQL } from "./codeql";
import { UserConfig } from "./config/db-config";
import * as file from "./config/file";
import * as configUtils from "./config-utils";
import { ActionsEnvVars } from "./environment";
import * as errorMessages from "./error-messages";
import { Feature } from "./feature-flags";
import { RepositoryProperties } from "./feature-flags/properties";
@@ -42,6 +43,7 @@ import {
SAMPLE_DOTCOM_API_DETAILS,
AssertableTarget,
RecordingLogger,
getTestEnv,
} from "./testing-utils";
import {
GitHubVariant,
@@ -151,9 +153,11 @@ function mockListLanguages(languages: string[]) {
test.serial("load empty config", async (t) => {
return await withTmpDir(async (tempDir) => {
const logger = new RecordingLogger();
const state = initAllState({ logger });
const languages = "javascript,python";
setupActionsVars(tempDir, tempDir);
setupActionsVars(tempDir, tempDir, {}, state.env);
const codeql = createStubCodeQL({
async resolveLanguages() {
@@ -166,7 +170,6 @@ test.serial("load empty config", async (t) => {
},
});
const state = initAllState({ logger });
const config = await configUtils.initConfig(
state,
createTestInitConfigInputs({
@@ -197,9 +200,10 @@ test.serial("load empty config", async (t) => {
test.serial("load code quality config", async (t) => {
return await withTmpDir(async (tempDir) => {
const logger = new RecordingLogger();
const state = initAllState({ logger });
const languages = "actions";
setupActionsVars(tempDir, tempDir);
setupActionsVars(tempDir, tempDir, {}, state.env);
const codeql = createStubCodeQL({
async resolveLanguages() {
@@ -211,7 +215,6 @@ test.serial("load code quality config", async (t) => {
},
});
const state = initAllState({ logger });
const config = await configUtils.initConfig(
state,
createTestInitConfigInputs({
@@ -253,9 +256,10 @@ test.serial(
async (t) => {
return await withTmpDir(async (tempDir) => {
const logger = new RecordingLogger();
const state = initAllState({ logger });
const languages = "javascript";
setupActionsVars(tempDir, tempDir);
setupActionsVars(tempDir, tempDir, {}, state.env);
const codeql = createStubCodeQL({
async resolveLanguages() {
@@ -291,7 +295,6 @@ test.serial(
repositoryProperties,
});
const state = initAllState({ logger });
await t.notThrowsAsync(async () => {
const config = await configUtils.initConfig(
state,
@@ -316,6 +319,8 @@ test.serial(
test.serial("loading a saved config produces the same config", async (t) => {
return await withTmpDir(async (tempDir) => {
const logger = new RecordingLogger();
const state = initAllState({ logger });
setupActionsVars(tempDir, tempDir, {}, state.env);
const codeql = createStubCodeQL({
async resolveLanguages() {
@@ -334,7 +339,6 @@ test.serial("loading a saved config produces the same config", async (t) => {
// Sanity check that getConfig returns undefined before we have called initConfig
t.deepEqual(await configUtils.getConfig(tempDir, logger), undefined);
const state = initAllState({ logger });
const config1 = await configUtils.initConfig(
state,
createTestInitConfigInputs({
@@ -364,6 +368,8 @@ test.serial("loading a saved config produces the same config", async (t) => {
test.serial("loading config with version mismatch throws", async (t) => {
return await withTmpDir(async (tempDir) => {
const logger = new RecordingLogger();
const state = initAllState({ logger });
setupActionsVars(tempDir, tempDir, {}, state.env);
const codeql = createStubCodeQL({
async resolveLanguages() {
@@ -387,7 +393,6 @@ test.serial("loading config with version mismatch throws", async (t) => {
.stub(actionsUtil, "getActionVersion")
.returns("does-not-exist");
const state = initAllState({ logger });
const config = await configUtils.initConfig(
state,
createTestInitConfigInputs({
@@ -497,7 +502,8 @@ const otherConfigFileContents = `
test.serial("load non-empty input", async (t) => {
return await withTmpDir(async (tempDir) => {
setupActionsVars(tempDir, tempDir);
const state = initAllState();
setupActionsVars(tempDir, tempDir, {}, state.env);
const codeql = createStubCodeQL({
async resolveLanguages() {
@@ -538,7 +544,6 @@ test.serial("load non-empty input", async (t) => {
const languagesInput = "javascript";
const configFilePath = createConfigFile(otherConfigFileContents, tempDir);
const state = initAllState();
const actualConfig = await configUtils.initConfig(
state,
createTestInitConfigInputs({
@@ -657,7 +662,9 @@ test.serial("API client used when reading remote config", async (t) => {
const configFile = "octo-org/codeql-config/config.yaml@main";
const languagesInput = "javascript";
const state = initAllState();
const state = initAllState({
env: getTestEnv({ [ActionsEnvVars.RUNNER_TEMP]: tempDir }),
});
await configUtils.initConfig(
state,
createTestInitConfigInputs({

View File

@@ -48,7 +48,10 @@ import {
addNoLanguageDiagnostic,
makeTelemetryDiagnostic,
} from "./diagnostics";
import { prepareDiffInformedAnalysis } from "./diff-informed-analysis-utils";
import {
getDiffRangesJsonFilePath,
prepareDiffInformedAnalysis,
} from "./diff-informed";
import { EnvVar, exportEnvVar, getEnv } from "./environment";
import * as errorMessages from "./error-messages";
import { Feature, FeatureEnablement, FeatureWithoutCLI } from "./feature-flags";
@@ -1288,9 +1291,10 @@ export async function initConfig(
}
const hasDiffRanges = await prepareDiffInformedAnalysis(
inputs.codeql,
inputs.features,
logger,
inputs.features,
inputs.codeql,
getDiffRangesJsonFilePath(actionState.env),
);
await applyIncrementalAnalysisSettings(

View File

@@ -1,16 +1,12 @@
import test, { ExecutionContext } from "ava";
import * as sinon from "sinon";
import * as actionsUtil from "./actions-util";
import type { PullRequestBranches } from "./actions-util";
import * as apiClient from "./api-client";
import {
getDiffInformedAnalysisBranches,
prepareDiffInformedAnalysis,
exportedForTesting,
} from "./diff-informed-analysis-utils";
import { Feature, FeatureEnablement, initFeatures } from "./feature-flags";
import { parseRepositoryNwo } from "./repository";
import * as actionsUtil from "../actions-util";
import type { PullRequestBranches } from "../actions-util";
import * as apiClient from "../api-client";
import { getEnv } from "../environment";
import { Feature, FeatureEnablement, initFeatures } from "../feature-flags";
import { parseRepositoryNwo } from "../repository";
import {
setupTests,
createFeatures,
@@ -19,9 +15,16 @@ import {
setupActionsVars,
makeMacro,
RecordingLogger,
} from "./testing-utils";
import { GitHubVariant, withTmpDir } from "./util";
import type { GitHubVersion } from "./util";
} from "../testing-utils";
import { GitHubVariant, withTmpDir } from "../util";
import type { GitHubVersion } from "../util";
import {
getDiffInformedAnalysisBranches,
prepareDiffInformedAnalysis,
exportedForTesting,
getDiffRangesJsonFilePath,
} from ".";
setupTests(test);
@@ -192,9 +195,10 @@ test.serial(
.resolves({ type: GitHubVariant.DOTCOM });
const result = await prepareDiffInformedAnalysis(
codeql,
features,
logger,
features,
codeql,
getDiffRangesJsonFilePath(getEnv()),
);
t.false(result);
@@ -222,9 +226,10 @@ test.serial(
};
const result = await prepareDiffInformedAnalysis(
codeql,
features,
logger,
features,
codeql,
getDiffRangesJsonFilePath(getEnv()),
);
t.false(result);
@@ -259,9 +264,10 @@ test.serial(
} as any);
const result = await prepareDiffInformedAnalysis(
codeql,
features,
logger,
features,
codeql,
getDiffRangesJsonFilePath(getEnv()),
);
t.true(result);
@@ -296,9 +302,10 @@ test.serial(
} as any);
const result = await prepareDiffInformedAnalysis(
codeql,
features,
logger,
features,
codeql,
getDiffRangesJsonFilePath(getEnv()),
);
t.false(result);

View File

@@ -1,23 +1,43 @@
import * as fs from "fs";
import path from "path";
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";
import * as actionsUtil from "../actions-util";
import type { PullRequestBranches } from "../actions-util";
import { getApiClient, getGitHubVersion } from "../api-client";
import type { CodeQL } from "../codeql";
import { type Env, getEnv } from "../environment";
import type { FeatureEnablement } from "../feature-flags";
import { Feature } from "../feature-flags/features";
import { Logger } from "../logging";
import { getRepositoryNwoFromEnv } from "../repository";
import { getErrorMessage, GitHubVariant, satisfiesGHESVersion } from "../util";
/** The filename used to persist PR diff ranges. */
const PR_DIFF_RANGE_JSON_FILENAME = "pr-diff-range.json";
/**
* Gets the full path of the file used to persist PR diff ranges.
*/
export function getDiffRangesJsonFilePath(env: Env = getEnv()): string {
return path.join(
actionsUtil.getTemporaryDirectory(env),
PR_DIFF_RANGE_JSON_FILENAME,
);
}
/**
* This interface is an abbreviated version of the file diff object returned by
* the GitHub API.
*/
interface FileDiff {
export interface FileDiff {
/** The name of the file that has changed. */
filename: string;
/** The number of changes. */
changes: number;
// A patch may be absent if the file is binary, if the file diff is too large,
// or if the file is unchanged.
/**
* 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;
}
@@ -63,9 +83,10 @@ export async function getDiffInformedAnalysisBranches(
* and are therefore available for use, `false` otherwise.
*/
export async function prepareDiffInformedAnalysis(
codeql: CodeQL,
features: FeatureEnablement,
logger: Logger,
features: FeatureEnablement,
codeql: CodeQL,
jsonFilePath: string,
): Promise<boolean> {
let branches: PullRequestBranches | undefined;
try {
@@ -84,7 +105,7 @@ export async function prepareDiffInformedAnalysis(
}
try {
return await computeAndPersistDiffRanges(branches, logger);
return await computeAndPersistDiffRanges(logger, jsonFilePath, branches);
} catch (e) {
logger.warning(
`Failed to compute diff-informed analysis ranges: ${getErrorMessage(e)}`,
@@ -93,29 +114,45 @@ export async function prepareDiffInformedAnalysis(
}
}
/** 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 jsonFilePath}.
*
* @param logger The logger to use.
* @param jsonFilePath The path of the file to write.
* @param ranges The changed line ranges to write.
*/
export function writeDiffRangesJsonFile(
logger: Logger,
jsonFilePath: string,
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 jsonFilePath}.
*
* @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,
jsonFilePath: string,
): DiffThunkRange[] | undefined {
const jsonFilePath = actionsUtil.getDiffRangesJsonFilePath();
if (!fs.existsSync(jsonFilePath)) {
logger.debug(`Diff ranges JSON file does not exist at ${jsonFilePath}`);
return undefined;
@@ -138,12 +175,12 @@ export function readDiffRangesJsonFile(
* 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
* @returns An array of tuples, where each tuple contains the relative path of a
* file (relative to the repository root, as returned by the GitHub compare API),
* the start line and the end line (both 1-based and inclusive) of an
* added or modified range in that file. Returns `undefined` if the action was
* not triggered by a pull request or if there was an error.
* @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,
@@ -180,22 +217,25 @@ export async function getPullRequestEditedDiffRanges(
* 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 logger The logger to use.
* @param jsonFilePath The path of the JSON file to write.
* @param branches The base and head branches of the pull request, as returned
* by `getDiffInformedAnalysisBranches`.
* @param logger
*
* @returns `true` if the diff ranges were successfully computed and persisted,
* otherwise `false`.
*/
export async function computeAndPersistDiffRanges(
branches: PullRequestBranches,
logger: Logger,
jsonFilePath: string,
branches: PullRequestBranches,
): Promise<boolean> {
logger.info("Computing PR diff ranges...");
const ranges = await getPullRequestEditedDiffRanges(branches, logger);
if (ranges === undefined) {
return false;
}
writeDiffRangesJsonFile(logger, ranges);
writeDiffRangesJsonFile(logger, jsonFilePath, ranges);
const distinctFiles = new Set(ranges.map((r) => r.path)).size;
logger.info(
`Persisted ${ranges.length} diff range(s) across ${distinctFiles} file(s).`,
@@ -203,6 +243,19 @@ export async function computeAndPersistDiffRanges(
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,
@@ -243,6 +296,16 @@ async function getFileDiffsWithBasehead(
}
}
/**
* 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,

View File

@@ -5,6 +5,7 @@ import test from "ava";
import * as sinon from "sinon";
import * as actionsUtil from "../actions-util";
import * as diffInformed from "../diff-informed";
import * as gitUtils from "../git-utils";
import {
createTestConfig,
@@ -60,7 +61,7 @@ test.serial(
const diffRangeFilePath = path.join(tempDir, "pr-diff-range.json");
sinon.stub(actionsUtil, "getTemporaryDirectory").returns(tempDir);
sinon
.stub(actionsUtil, "getDiffRangesJsonFilePath")
.stub(diffInformed, "getDiffRangesJsonFilePath")
.returns(diffRangeFilePath);
sinon.stub(gitUtils, "getGitRoot").resolves(sourceRoot);
const changesFilePath = await writeOverlayChangesFile(
@@ -126,7 +127,7 @@ test.serial(
const diffRangeFilePath = path.join(tempDir, "pr-diff-range.json");
sinon.stub(actionsUtil, "getTemporaryDirectory").returns(tempDir);
sinon
.stub(actionsUtil, "getDiffRangesJsonFilePath")
.stub(diffInformed, "getDiffRangesJsonFilePath")
.returns(diffRangeFilePath);
sinon.stub(gitUtils, "getGitRoot").resolves(sourceRoot);
@@ -196,7 +197,7 @@ test.serial(
const diffRangeFilePath = path.join(tempDir, "pr-diff-range.json");
sinon.stub(actionsUtil, "getTemporaryDirectory").returns(tempDir);
sinon
.stub(actionsUtil, "getDiffRangesJsonFilePath")
.stub(diffInformed, "getDiffRangesJsonFilePath")
.returns(diffRangeFilePath);
sinon.stub(gitUtils, "getGitRoot").resolves(sourceRoot);
@@ -259,7 +260,7 @@ test.serial(
const diffRangeFilePath = path.join(tempDir, "pr-diff-range.json");
sinon.stub(actionsUtil, "getTemporaryDirectory").returns(tempDir);
sinon
.stub(actionsUtil, "getDiffRangesJsonFilePath")
.stub(diffInformed, "getDiffRangesJsonFilePath")
.returns(diffRangeFilePath);
// getGitRoot returns the repo root (parent of sourceRoot)
sinon.stub(gitUtils, "getGitRoot").resolves(repoRoot);

View File

@@ -1,9 +1,9 @@
import * as fs from "fs";
import * as path from "path";
import * as actionsUtil from "../actions-util";
import { getOptionalInput, getTemporaryDirectory } from "../actions-util";
import { type Config } from "../config-utils";
import { getDiffRangesJsonFilePath } from "../diff-informed";
import { getFileOidsUnderPath, getGitRoot } from "../git-utils";
import { Logger } from "../logging";
import { getBaseDatabaseOidsFilePath } from "../util";
@@ -135,7 +135,7 @@ async function getDiffRangeFilePaths(
sourceRoot: string,
logger: Logger,
): Promise<string[]> {
const jsonFilePath = actionsUtil.getDiffRangesJsonFilePath();
const jsonFilePath = getDiffRangesJsonFilePath();
if (!fs.existsSync(jsonFilePath)) {
logger.debug(

View File

@@ -9,7 +9,7 @@ import * as sinon from "sinon";
import * as analyses from "./analyses";
import { AnalysisKind, CodeQuality, CodeScanning } from "./analyses";
import * as api from "./api-client";
import * as diffUtils from "./diff-informed-analysis-utils";
import * as diffUtils from "./diff-informed";
import { getEnv } from "./environment";
import { Logger } from "./logging";
import * as sarif from "./sarif";
@@ -1074,6 +1074,7 @@ function runFilterAlertsByDiffRange(
logger: Logger = new RecordingLogger(),
): Partial<sarif.Log> {
sinon.stub(diffUtils, "readDiffRangesJsonFile").returns(diffRanges);
sinon.stub(diffUtils, "getDiffRangesJsonFilePath").returns("");
return uploadLib.filterAlertsByDiffRange(logger, input);
}

View File

@@ -13,7 +13,10 @@ import * as api from "./api-client";
import { getGitHubVersion, wrapApiConfigurationError } from "./api-client";
import { CodeQL, getCodeQL } from "./codeql";
import type { ActionConfig } from "./config/action-config";
import { readDiffRangesJsonFile } from "./diff-informed-analysis-utils";
import {
getDiffRangesJsonFilePath,
readDiffRangesJsonFile,
} from "./diff-informed";
import { ActionsEnvVars, EnvVar, exportEnvVar } from "./environment";
import { FeatureEnablement } from "./feature-flags";
import * as fingerprints from "./fingerprints";
@@ -1079,7 +1082,10 @@ export function filterAlertsByDiffRange(
logger: Logger,
sarifLog: Partial<sarif.Log>,
): Partial<sarif.Log> {
const diffRanges = readDiffRangesJsonFile(logger);
const diffRanges = readDiffRangesJsonFile(
logger,
getDiffRangesJsonFilePath(),
);
if (!diffRanges?.length) {
return sarifLog;
}