Merge branch 'main' into henrymercer/parse-category

This commit is contained in:
Henry Mercer
2022-11-25 09:58:27 +00:00
47 changed files with 825 additions and 115 deletions

View File

@@ -14,7 +14,7 @@ import { GitHubApiDetails } from "./api-client";
import * as codeql from "./codeql";
import { AugmentationProperties, Config } from "./config-utils";
import * as defaults from "./defaults.json";
import { Feature, FeatureEnablement } from "./feature-flags";
import { Feature } from "./feature-flags";
import { Language } from "./languages";
import { getRunnerLogger } from "./logging";
import { setupTests, setupActionsVars, createFeatures } from "./testing-utils";
@@ -70,14 +70,14 @@ test.beforeEach(() => {
async function mockApiAndSetupCodeQL({
apiDetails,
featureEnablement,
bypassToolcache,
isPinned,
tmpDir,
toolsInput,
version,
}: {
apiDetails?: GitHubApiDetails;
featureEnablement?: FeatureEnablement;
bypassToolcache?: boolean;
isPinned?: boolean;
tmpDir: string;
toolsInput?: { input?: string };
@@ -110,7 +110,7 @@ async function mockApiAndSetupCodeQL({
apiDetails ?? sampleApiDetails,
tmpDir,
util.GitHubVariant.DOTCOM,
featureEnablement ?? createFeatures([]),
!!bypassToolcache,
getRunnerLogger(true),
false
);
@@ -173,7 +173,7 @@ test("don't download codeql bundle cache with pinned different version cached",
sampleApiDetails,
tmpDir,
util.GitHubVariant.DOTCOM,
createFeatures([]),
false,
getRunnerLogger(true),
false
);
@@ -281,9 +281,7 @@ for (const [
await mockApiAndSetupCodeQL({
version: defaults.bundleVersion,
apiDetails: sampleApiDetails,
featureEnablement: createFeatures(
isFeatureEnabled ? [Feature.BypassToolcacheEnabled] : []
),
bypassToolcache: isFeatureEnabled,
toolsInput: { input: toolsInput },
tmpDir,
});
@@ -339,7 +337,7 @@ test("download codeql bundle from github ae endpoint", async (t) => {
sampleGHAEApiDetails,
tmpDir,
util.GitHubVariant.GHAE,
createFeatures([]),
false,
getRunnerLogger(true),
false
);

View File

@@ -415,7 +415,7 @@ export async function setupCodeQL(
apiDetails: api.GitHubApiDetails,
tempDir: string,
variant: util.GitHubVariant,
features: FeatureEnablement,
bypassToolcache: boolean,
logger: Logger,
checkVersion: boolean
): Promise<{ codeql: CodeQL; toolsVersion: string }> {
@@ -429,8 +429,7 @@ export async function setupCodeQL(
// the toolcache when the appropriate feature is enabled. This
// allows us to quickly rollback a broken bundle that has made its way
// into the toolcache.
codeqlURL === undefined &&
(await features.getValue(Feature.BypassToolcacheEnabled))
codeqlURL === undefined && bypassToolcache
? "a specific version of CodeQL was not requested and the bypass toolcache feature is enabled"
: undefined;
const forceLatest = forceLatestReason !== undefined;

View File

@@ -12,7 +12,12 @@ import * as configUtils from "./config-utils";
import { Feature } from "./feature-flags";
import { Language } from "./languages";
import { getRunnerLogger, Logger } from "./logging";
import { setupTests, createFeatures } from "./testing-utils";
import { parseRepositoryNwo } from "./repository";
import {
setupTests,
createFeatures,
mockLanguagesInRepo as mockLanguagesInRepo,
} from "./testing-utils";
import * as util from "./util";
setupTests(test);
@@ -1759,11 +1764,7 @@ function parseInputAndConfigMacro(
parseInputAndConfigMacro.title = (providedTitle: string) =>
`Parse Packs input and config: ${providedTitle}`;
const mockLogger = {
info: (message: string) => {
console.log(message);
},
} as Logger;
const mockLogger = getRunnerLogger(true);
function parseInputAndConfigErrorMacro(
t: ExecutionContext<unknown>,
@@ -2449,3 +2450,105 @@ test("downloadPacks-with-registries fails with invalid registries block", async
);
});
});
// getLanguages
const mockRepositoryNwo = parseRepositoryNwo("owner/repo");
// eslint-disable-next-line github/array-foreach
[
{
name: "languages from input",
codeqlResolvedLanguages: ["javascript", "java", "python"],
languagesInput: "jAvAscript, \n jaVa",
languagesInRepository: ["SwiFt", "other"],
expectedLanguages: ["javascript", "java"],
expectedApiCall: false,
},
{
name: "languages from github api",
codeqlResolvedLanguages: ["javascript", "java", "python"],
languagesInput: "",
languagesInRepository: [" jAvAscript\n \t", " jaVa", "SwiFt", "other"],
expectedLanguages: ["javascript", "java"],
expectedApiCall: true,
},
{
name: "aliases from input",
codeqlResolvedLanguages: ["javascript", "csharp", "cpp", "java", "python"],
languagesInput: " typEscript\n \t, C#, c , KoTlin",
languagesInRepository: ["SwiFt", "other"],
expectedLanguages: ["javascript", "csharp", "cpp", "java"],
expectedApiCall: false,
},
{
name: "duplicate languages from input",
codeqlResolvedLanguages: ["javascript", "java", "python"],
languagesInput: "jAvAscript, \n jaVa, kotlin, typescript",
languagesInRepository: ["SwiFt", "other"],
expectedLanguages: ["javascript", "java"],
expectedApiCall: false,
},
{
name: "aliases from github api",
codeqlResolvedLanguages: ["javascript", "csharp", "cpp", "java", "python"],
languagesInput: "",
languagesInRepository: [" typEscript\n \t", " C#", "c", "other"],
expectedLanguages: ["javascript", "csharp", "cpp"],
expectedApiCall: true,
},
{
name: "no languages",
codeqlResolvedLanguages: ["javascript", "java", "python"],
languagesInput: "",
languagesInRepository: [],
expectedApiCall: true,
expectedError: configUtils.getNoLanguagesError(),
},
{
name: "unrecognized languages from input",
codeqlResolvedLanguages: ["javascript", "java", "python"],
languagesInput: "a, b, c, javascript",
languagesInRepository: [],
expectedApiCall: false,
expectedError: configUtils.getUnknownLanguagesError(["a", "b"]),
},
].forEach((args) => {
test(`getLanguages: ${args.name}`, async (t) => {
const mockRequest = mockLanguagesInRepo(args.languagesInRepository);
const languages = args.codeqlResolvedLanguages.reduce(
(acc, lang) => ({
...acc,
[lang]: true,
}),
{}
);
const codeQL = setCodeQL({
resolveLanguages: () => Promise.resolve(languages),
});
if (args.expectedLanguages) {
// happy path
const actualLanguages = await configUtils.getLanguages(
codeQL,
args.languagesInput,
mockRepositoryNwo,
mockLogger
);
t.deepEqual(actualLanguages.sort(), args.expectedLanguages.sort());
} else {
// there is an error
await t.throwsAsync(
async () =>
await configUtils.getLanguages(
codeQL,
args.languagesInput,
mockRepositoryNwo,
mockLogger
),
{ message: args.expectedError }
);
}
t.deepEqual(mockRequest.called, args.expectedApiCall);
});
});

View File

@@ -15,7 +15,12 @@ import {
} from "./codeql";
import * as externalQueries from "./external-queries";
import { Feature, FeatureEnablement } from "./feature-flags";
import { Language, parseLanguage } from "./languages";
import {
Language,
LanguageOrAlias,
parseLanguage,
resolveAlias,
} from "./languages";
import { Logger } from "./logging";
import { RepositoryNwo } from "./repository";
import { downloadTrapCaches } from "./trap-caching";
@@ -852,12 +857,13 @@ export function getUnknownLanguagesError(languages: string[]): string {
}
/**
* Gets the set of languages in the current repository
* Gets the set of languages in the current repository that are
* scannable by CodeQL.
*/
async function getLanguagesInRepo(
export async function getLanguagesInRepo(
repository: RepositoryNwo,
logger: Logger
): Promise<Language[]> {
): Promise<LanguageOrAlias[]> {
logger.debug(`GitHub repo ${repository.owner} ${repository.repo}`);
const response = await api.getApiClient().repos.listLanguages({
owner: repository.owner,
@@ -870,7 +876,7 @@ async function getLanguagesInRepo(
// When we pick a language to autobuild we want to pick the most popular traced language
// Since sets in javascript maintain insertion order, using a set here and then splatting it
// into an array gives us an array of languages ordered by popularity
const languages: Set<Language> = new Set();
const languages: Set<LanguageOrAlias> = new Set();
for (const lang of Object.keys(response.data)) {
const parsedLang = parseLanguage(lang);
if (parsedLang !== undefined) {
@@ -890,27 +896,27 @@ async function getLanguagesInRepo(
* If no languages could be detected from either the workflow or the repository
* then throw an error.
*/
async function getLanguages(
export async function getLanguages(
codeQL: CodeQL,
languagesInput: string | undefined,
repository: RepositoryNwo,
logger: Logger
): Promise<Language[]> {
// Obtain from action input 'languages' if set
let languages = (languagesInput || "")
.split(",")
.map((x) => x.trim())
.filter((x) => x.length > 0);
logger.info(`Languages from configuration: ${JSON.stringify(languages)}`);
// Obtain languages without filtering them.
const { rawLanguages, autodetected } = await getRawLanguages(
languagesInput,
repository,
logger
);
if (languages.length === 0) {
// Obtain languages as all languages in the repo that can be analysed
languages = await getLanguagesInRepo(repository, logger);
let languages = rawLanguages.map(resolveAlias);
if (autodetected) {
const availableLanguages = await codeQL.resolveLanguages();
languages = languages.filter((value) => value in availableLanguages);
logger.info(
`Automatically detected languages: ${JSON.stringify(languages)}`
);
logger.info(`Automatically detected languages: ${languages.join(", ")}`);
} else {
logger.info(`Languages from configuration: ${languages.join(", ")}`);
}
// If the languages parameter was not given and no languages were
@@ -923,13 +929,17 @@ async function getLanguages(
const parsedLanguages: Language[] = [];
const unknownLanguages: string[] = [];
for (const language of languages) {
const parsedLanguage = parseLanguage(language);
// We know this is not an alias since we resolved it above.
const parsedLanguage = parseLanguage(language) as Language;
if (parsedLanguage === undefined) {
unknownLanguages.push(language);
} else if (parsedLanguages.indexOf(parsedLanguage) === -1) {
} else if (!parsedLanguages.includes(parsedLanguage)) {
parsedLanguages.push(parsedLanguage);
}
}
// Any unknown languages here would have come directly from the input
// since we filter unknown languages coming from the GitHub API.
if (unknownLanguages.length > 0) {
throw new Error(getUnknownLanguagesError(unknownLanguages));
}
@@ -937,6 +947,38 @@ async function getLanguages(
return parsedLanguages;
}
/**
* Gets the set of languages in the current repository without checking to
* see if these languages are actually supported by CodeQL.
*
* @param languagesInput The languages from the workflow input
* @param repository the owner/name of the repository
* @param logger a logger
* @returns A tuple containing a list of languages in this repository that might be
* analyzable and whether or not this list was determined automatically.
*/
export async function getRawLanguages(
languagesInput: string | undefined,
repository: RepositoryNwo,
logger: Logger
) {
// Obtain from action input 'languages' if set
let rawLanguages = (languagesInput || "")
.split(",")
.map((x) => x.trim().toLowerCase())
.filter((x) => x.length > 0);
let autodetected: boolean;
if (rawLanguages.length) {
autodetected = false;
} else {
autodetected = true;
// Obtain all languages in the repo that can be analysed
rawLanguages = (await getLanguagesInRepo(repository, logger)) as string[];
}
return { rawLanguages, autodetected };
}
async function addQueriesAndPacksFromWorkflow(
codeQL: CodeQL,
queriesInput: string,

View File

@@ -1,3 +1,3 @@
{
"bundleVersion": "codeql-bundle-20221105"
"bundleVersion": "codeql-bundle-20221123"
}

View File

@@ -13,6 +13,7 @@ export interface FeatureEnablement {
export enum Feature {
BypassToolcacheEnabled = "bypass_toolcache_enabled",
BypassToolcacheKotlinSwiftEnabled = "bypass_toolcache_kotlin_swift_enabled",
CliConfigFileEnabled = "cli_config_file_enabled",
DisableKotlinAnalysisEnabled = "disable_kotlin_analysis_enabled",
FileBaselineInformationEnabled = "file_baseline_information_enabled",
@@ -26,6 +27,14 @@ export const featureConfig: Record<
> = {
[Feature.BypassToolcacheEnabled]: {
envVar: "CODEQL_BYPASS_TOOLCACHE",
// Cannot specify a minimum version because this flag is checked before we have
// access to the CodeQL instance.
minimumVersion: undefined,
},
[Feature.BypassToolcacheKotlinSwiftEnabled]: {
envVar: "CODEQL_BYPASS_TOOLCACHE_KOTLIN_SWIFT",
// Cannot specify a minimum version because this flag is checked before we have
// access to the CodeQL instance.
minimumVersion: undefined,
},
[Feature.DisableKotlinAnalysisEnabled]: {

View File

@@ -40,6 +40,7 @@ import {
getThreadsFlagValue,
initializeEnvironment,
isHostedRunner,
shouldBypassToolcache,
} from "./util";
import { validateWorkflow } from "./workflow";
@@ -186,7 +187,13 @@ async function run() {
apiDetails,
getTemporaryDirectory(),
gitHubVersion.type,
features,
await shouldBypassToolcache(
features,
getOptionalInput("tools"),
getOptionalInput("languages"),
repositoryNwo,
logger
),
logger
);
codeql = initCodeQLResult.codeql;

View File

@@ -20,7 +20,7 @@ export async function initCodeQL(
apiDetails: GitHubApiDetails,
tempDir: string,
variant: util.GitHubVariant,
featureEnablement: FeatureEnablement,
bypassToolcache: boolean,
logger: Logger
): Promise<{ codeql: CodeQL; toolsVersion: string }> {
logger.startGroup("Setup CodeQL tools");
@@ -29,7 +29,7 @@ export async function initCodeQL(
apiDetails,
tempDir,
variant,
featureEnablement,
bypassToolcache,
logger,
true
);

View File

@@ -20,11 +20,15 @@ test("parseLanguage", async (t) => {
t.deepEqual(parseLanguage("python"), Language.python);
// Aliases
t.deepEqual(parseLanguage("c"), Language.cpp);
t.deepEqual(parseLanguage("c++"), Language.cpp);
t.deepEqual(parseLanguage("c#"), Language.csharp);
t.deepEqual(parseLanguage("kotlin"), Language.java);
t.deepEqual(parseLanguage("typescript"), Language.javascript);
t.deepEqual(parseLanguage("c"), "c");
t.deepEqual(parseLanguage("c++"), "c++");
t.deepEqual(parseLanguage("c#"), "c#");
t.deepEqual(parseLanguage("kotlin"), "kotlin");
t.deepEqual(parseLanguage("typescript"), "typescript");
// spaces and case-insensitivity
t.deepEqual(parseLanguage(" \t\nCsHaRp\t\t"), Language.csharp);
t.deepEqual(parseLanguage(" \t\nkOtLin\t\t"), "kotlin");
// Not matches
t.deepEqual(parseLanguage("foo"), undefined);

View File

@@ -11,7 +11,7 @@ export enum Language {
}
// Additional names for languages
const LANGUAGE_ALIASES: { [lang: string]: Language } = {
export const LANGUAGE_ALIASES: { [lang: string]: Language } = {
c: Language.cpp,
"c++": Language.cpp,
"c#": Language.csharp,
@@ -19,19 +19,37 @@ const LANGUAGE_ALIASES: { [lang: string]: Language } = {
typescript: Language.javascript,
};
// Translate from user input or GitHub's API names for languages to CodeQL's names for languages
export function parseLanguage(language: string): Language | undefined {
export type LanguageOrAlias = Language | keyof typeof LANGUAGE_ALIASES;
export const KOTLIN_SWIFT_BYPASS = ["kotlin", "swift"];
export function resolveAlias(lang: LanguageOrAlias): Language {
return LANGUAGE_ALIASES[lang] || lang;
}
/**
* Translate from user input or GitHub's API names for languages to CodeQL's
* names for languages. This does not translate a language alias to the actual
* language used by CodeQL.
*
* @param language The language to translate.
* @returns A language supported by CodeQL, an alias for a language, or
* `undefined` if the input language cannot be parsed into a langauge supported
* by CodeQL.
*/
export function parseLanguage(language: string): LanguageOrAlias | undefined {
// Normalise to lower case
language = language.toLowerCase();
language = language.trim().toLowerCase();
// See if it's an exact match
if (language in Language) {
return language as Language;
}
// Check language aliases
// Check language aliases, but return the original language name,
// the alias will be resolved later.
if (language in LANGUAGE_ALIASES) {
return LANGUAGE_ALIASES[language];
return language;
}
return undefined;

View File

@@ -162,6 +162,26 @@ export function mockFeatureFlagApiEndpoint(
sinon.stub(apiClient, "getApiClient").value(() => client);
}
export function mockLanguagesInRepo(languages: string[]) {
const mockClient = sinon.stub(apiClient, "getApiClient");
const listLanguages = sinon.stub().resolves({
status: 200,
data: languages.reduce((acc, lang) => {
acc[lang] = 1;
return acc;
}, {}),
headers: {},
url: "GET /repos/:owner/:repo/languages",
});
mockClient.returns({
repos: {
listLanguages,
},
} as any);
return listLanguages;
}
export function mockCodeQLVersion(version) {
return {
async getVersion() {

View File

@@ -9,8 +9,14 @@ import * as sinon from "sinon";
import * as api from "./api-client";
import { Config } from "./config-utils";
import { Feature } from "./feature-flags";
import { getRunnerLogger } from "./logging";
import { setupTests } from "./testing-utils";
import { parseRepositoryNwo } from "./repository";
import {
createFeatures,
mockLanguagesInRepo,
setupTests,
} from "./testing-utils";
import * as util from "./util";
setupTests(test);
@@ -449,3 +455,123 @@ test("withTimeout doesn't call callback if promise resolves", async (t) => {
t.deepEqual(shortTaskTimedOut, false);
t.deepEqual(result, 99);
});
const mockRepositoryNwo = parseRepositoryNwo("owner/repo");
// eslint-disable-next-line github/array-foreach
[
{
name: "disabled",
features: [],
hasCustomCodeQL: false,
languagesInput: undefined,
languagesInRepository: [],
expected: false,
expectedApiCall: false,
},
{
name: "disabled even though swift kotlin bypassed",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: undefined,
languagesInRepository: [],
expected: false,
expectedApiCall: true,
},
{
name: "disabled even though swift kotlin analyzed",
features: [],
hasCustomCodeQL: false,
languagesInput: " sWiFt , KoTlIn ",
languagesInRepository: [],
expected: false,
expectedApiCall: false,
},
{
name: "toolcache bypass all",
features: [Feature.BypassToolcacheEnabled],
hasCustomCodeQL: false,
languagesInput: undefined,
languagesInRepository: [],
expected: true,
expectedApiCall: false,
},
{
name: "custom CodeQL",
features: [],
hasCustomCodeQL: true,
languagesInput: undefined,
languagesInRepository: [],
expected: true,
expectedApiCall: false,
},
{
name: "bypass swift",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: " sWiFt ,other",
languagesInRepository: [],
expected: true,
expectedApiCall: false,
},
{
name: "bypass kotlin",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: "other, KoTlIn ",
languagesInRepository: [],
expected: true,
expectedApiCall: false,
},
{
name: "bypass kotlin language from repository",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: "",
languagesInRepository: ["KoTlIn", "other"],
expected: true,
expectedApiCall: true,
},
{
name: "bypass swift language from repository",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: "",
languagesInRepository: ["SwiFt", "other"],
expected: true,
expectedApiCall: true,
},
{
name: "bypass java from input if there is kotlin in repository",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: "java",
languagesInRepository: ["kotlin", "other"],
expected: true,
expectedApiCall: true,
},
{
name: "don't bypass java from input if there is no kotlin in repository",
features: [Feature.BypassToolcacheKotlinSwiftEnabled],
hasCustomCodeQL: false,
languagesInput: "java",
languagesInRepository: ["java", "other"],
expected: false,
expectedApiCall: true,
},
].forEach((args) => {
test(`shouldBypassToolcache: ${args.name}`, async (t) => {
const mockRequest = mockLanguagesInRepo(args.languagesInRepository);
const mockLogger = getRunnerLogger(true);
const featureEnablement = createFeatures(args.features);
const codeqlUrl = args.hasCustomCodeQL ? "custom-codeql-url" : undefined;
const actual = await util.shouldBypassToolcache(
featureEnablement,
codeqlUrl,
args.languagesInput,
mockRepositoryNwo,
mockLogger
);
t.deepEqual(actual, args.expected);
t.deepEqual(mockRequest.called, args.expectedApiCall);
});
});

View File

@@ -14,12 +14,15 @@ import * as apiCompatibility from "./api-compatibility.json";
import { CodeQL, CODEQL_VERSION_NEW_TRACING } from "./codeql";
import {
Config,
getLanguagesInRepo,
getRawLanguages,
parsePacksSpecification,
prettyPrintPack,
} from "./config-utils";
import { Feature, FeatureEnablement } from "./feature-flags";
import { Language } from "./languages";
import { KOTLIN_SWIFT_BYPASS, Language } from "./languages";
import { Logger } from "./logging";
import { RepositoryNwo } from "./repository";
import { CODEQL_ACTION_TEST_MODE } from "./shared-environment";
/**
@@ -832,3 +835,60 @@ export function isHostedRunner() {
process.env["RUNNER_TOOL_CACHE"]?.includes("hostedtoolcache")
);
}
/**
*
* @param featuresEnablement The features enabled for the current run
* @param languagesInput Languages input from the workflow
* @param repository The owner/name of the repository
* @param logger A logger
* @returns A boolean indicating whether or not the toolcache should be bypassed and the latest codeql should be downloaded.
*/
export async function shouldBypassToolcache(
featuresEnablement: FeatureEnablement,
codeqlUrl: string | undefined,
languagesInput: string | undefined,
repository: RepositoryNwo,
logger: Logger
): Promise<boolean> {
// An explicit codeql url is specified, that means the toolcache will not be used.
if (codeqlUrl) {
return true;
}
// Check if the toolcache is disabled for all languages
if (await featuresEnablement.getValue(Feature.BypassToolcacheEnabled)) {
return true;
}
// Check if the toolcache is disabled for kotlin and swift.
if (
!(await featuresEnablement.getValue(
Feature.BypassToolcacheKotlinSwiftEnabled
))
) {
return false;
}
// Now check to see if kotlin or swift is one of the languages being analyzed.
const { rawLanguages, autodetected } = await getRawLanguages(
languagesInput,
repository,
logger
);
let bypass = rawLanguages.some((lang) => KOTLIN_SWIFT_BYPASS.includes(lang));
if (bypass) {
logger.info(
`Bypassing toolcache for kotlin or swift. Languages: ${rawLanguages}`
);
} else if (!autodetected && rawLanguages.includes(Language.java)) {
// special case: java was explicitly specified, but there might be
// some kotlin in the repository, so we need to make a request for that.
const langsInRepo = await getLanguagesInRepo(repository, logger);
if (langsInRepo.includes("kotlin")) {
logger.info(`Bypassing toolcache for kotlin.`);
bypass = true;
}
}
return bypass;
}