Add support for feature flagging via the GitHub API
This commit is contained in:
parent
e1f05902cd
commit
04671efa1d
12 changed files with 590 additions and 66 deletions
|
|
@ -11,9 +11,13 @@ import { setCodeQL } from "./codeql";
|
|||
import { Config } from "./config-utils";
|
||||
import { uploadDatabases } from "./database-upload";
|
||||
import { Language } from "./languages";
|
||||
import { Logger } from "./logging";
|
||||
import { RepositoryNwo } from "./repository";
|
||||
import { setupActionsVars, setupTests } from "./testing-utils";
|
||||
import {
|
||||
getRecordingLogger,
|
||||
LoggedMessage,
|
||||
setupActionsVars,
|
||||
setupTests,
|
||||
} from "./testing-utils";
|
||||
import {
|
||||
GitHubVariant,
|
||||
HTTPError,
|
||||
|
|
@ -51,35 +55,6 @@ function getTestConfig(tmpDir: string): Config {
|
|||
};
|
||||
}
|
||||
|
||||
interface LoggedMessage {
|
||||
type: "debug" | "info" | "warning" | "error";
|
||||
message: string | Error;
|
||||
}
|
||||
|
||||
function getRecordingLogger(messages: LoggedMessage[]): Logger {
|
||||
return {
|
||||
debug: (message: string) => {
|
||||
messages.push({ type: "debug", message });
|
||||
console.debug(message);
|
||||
},
|
||||
info: (message: string) => {
|
||||
messages.push({ type: "info", message });
|
||||
console.info(message);
|
||||
},
|
||||
warning: (message: string | Error) => {
|
||||
messages.push({ type: "warning", message });
|
||||
console.warn(message);
|
||||
},
|
||||
error: (message: string | Error) => {
|
||||
messages.push({ type: "error", message });
|
||||
console.error(message);
|
||||
},
|
||||
isDebug: () => true,
|
||||
startGroup: () => undefined,
|
||||
endGroup: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mockHttpRequests(
|
||||
optInStatusCode: number,
|
||||
useUploadDomain?: boolean,
|
||||
|
|
|
|||
202
src/feature-flags.test.ts
Normal file
202
src/feature-flags.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import * as github from "@actions/github";
|
||||
import test from "ava";
|
||||
import * as sinon from "sinon";
|
||||
|
||||
import * as apiClient from "./api-client";
|
||||
import { GitHubApiDetails } from "./api-client";
|
||||
import { GitHubFeatureFlags } from "./feature-flags";
|
||||
import {
|
||||
getRecordingLogger,
|
||||
LoggedMessage,
|
||||
setupActionsVars,
|
||||
setupTests,
|
||||
} from "./testing-utils";
|
||||
import * as util from "./util";
|
||||
import {
|
||||
GitHubVariant,
|
||||
HTTPError,
|
||||
initializeEnvironment,
|
||||
Mode,
|
||||
withTmpDir,
|
||||
} from "./util";
|
||||
|
||||
setupTests(test);
|
||||
|
||||
test.beforeEach(() => {
|
||||
initializeEnvironment(Mode.actions, "1.2.3");
|
||||
|
||||
sinon
|
||||
.stub(util, "getRequiredEnvParam")
|
||||
.withArgs("GITHUB_REPOSITORY")
|
||||
.returns("github/example");
|
||||
});
|
||||
|
||||
const testApiDetails: GitHubApiDetails = {
|
||||
auth: "1234",
|
||||
url: "https://github.com",
|
||||
};
|
||||
|
||||
function mockHttpRequests(
|
||||
responseStatusCode: number,
|
||||
flags: { [flagName: string]: boolean }
|
||||
) {
|
||||
// Passing an auth token is required, so we just use a dummy value
|
||||
const client = github.getOctokit("123");
|
||||
|
||||
const requestSpy = sinon.stub(client, "request");
|
||||
|
||||
const optInSpy = requestSpy.withArgs(
|
||||
"GET /repos/:owner/:repo/code-scanning/codeql-action/features"
|
||||
);
|
||||
if (responseStatusCode < 300) {
|
||||
optInSpy.resolves({
|
||||
status: responseStatusCode,
|
||||
data: flags,
|
||||
headers: {},
|
||||
url: "GET /repos/:owner/:repo/code-scanning/codeql-action/features",
|
||||
});
|
||||
} else {
|
||||
optInSpy.throws(new HTTPError("some error message", responseStatusCode));
|
||||
}
|
||||
|
||||
sinon.stub(apiClient, "getApiClient").value(() => client);
|
||||
}
|
||||
|
||||
const ALL_FEATURE_FLAGS_DISABLED_VARIANTS: Array<{
|
||||
description: string;
|
||||
gitHubVersion: util.GitHubVersion;
|
||||
}> = [
|
||||
{
|
||||
description: "GHES",
|
||||
gitHubVersion: { type: GitHubVariant.GHES, version: "3.0.0" },
|
||||
},
|
||||
{ description: "GHAE", gitHubVersion: { type: GitHubVariant.GHAE } },
|
||||
];
|
||||
|
||||
for (const variant of ALL_FEATURE_FLAGS_DISABLED_VARIANTS) {
|
||||
test(`All feature flags are disabled if running against ${variant.description}`, async (t) => {
|
||||
await withTmpDir(async (tmpDir) => {
|
||||
setupActionsVars(tmpDir, tmpDir);
|
||||
|
||||
const loggedMessages = [];
|
||||
const featureFlags = new GitHubFeatureFlags(
|
||||
variant.gitHubVersion,
|
||||
testApiDetails,
|
||||
getRecordingLogger(loggedMessages)
|
||||
);
|
||||
|
||||
t.assert((await featureFlags.getDatabaseUploadsEnabled()) === false);
|
||||
t.assert((await featureFlags.getMlPoweredQueriesEnabled()) === false);
|
||||
t.assert((await featureFlags.getUploadsDomainEnabled()) === false);
|
||||
|
||||
t.assert(
|
||||
loggedMessages.find(
|
||||
(v: LoggedMessage) =>
|
||||
v.type === "debug" &&
|
||||
v.message ===
|
||||
"Not running against github.com. Disabling all feature flags."
|
||||
) !== undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Feature flags are disabled if they're not returned in API response", async (t) => {
|
||||
await withTmpDir(async (tmpDir) => {
|
||||
setupActionsVars(tmpDir, tmpDir);
|
||||
|
||||
const loggedMessages = [];
|
||||
const featureFlags = new GitHubFeatureFlags(
|
||||
{ type: GitHubVariant.DOTCOM },
|
||||
testApiDetails,
|
||||
getRecordingLogger(loggedMessages)
|
||||
);
|
||||
|
||||
mockHttpRequests(200, {});
|
||||
|
||||
t.assert((await featureFlags.getDatabaseUploadsEnabled()) === false);
|
||||
t.assert((await featureFlags.getMlPoweredQueriesEnabled()) === false);
|
||||
t.assert((await featureFlags.getUploadsDomainEnabled()) === false);
|
||||
|
||||
for (const featureFlag of [
|
||||
"database_uploads_enabled",
|
||||
"ml_powered_queries_enabled",
|
||||
"uploads_domain_enabled",
|
||||
]) {
|
||||
t.assert(
|
||||
loggedMessages.find(
|
||||
(v: LoggedMessage) =>
|
||||
v.type === "debug" &&
|
||||
v.message ===
|
||||
`Feature flag '${featureFlag}' undefined in API response, considering it disabled.`
|
||||
) !== undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("All feature flags are disabled if the API request errors", async (t) => {
|
||||
await withTmpDir(async (tmpDir) => {
|
||||
setupActionsVars(tmpDir, tmpDir);
|
||||
|
||||
const loggedMessages = [];
|
||||
const featureFlags = new GitHubFeatureFlags(
|
||||
{ type: GitHubVariant.DOTCOM },
|
||||
testApiDetails,
|
||||
getRecordingLogger(loggedMessages)
|
||||
);
|
||||
|
||||
mockHttpRequests(500, {});
|
||||
|
||||
t.assert((await featureFlags.getDatabaseUploadsEnabled()) === false);
|
||||
t.assert((await featureFlags.getMlPoweredQueriesEnabled()) === false);
|
||||
t.assert((await featureFlags.getUploadsDomainEnabled()) === false);
|
||||
|
||||
t.assert(
|
||||
loggedMessages.find(
|
||||
(v: LoggedMessage) =>
|
||||
v.type === "info" &&
|
||||
v.message ===
|
||||
"Disabling all feature flags due to unknown error: Error: some error message"
|
||||
) !== undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const FEATURE_FLAGS = [
|
||||
"database_uploads_enabled",
|
||||
"ml_powered_queries_enabled",
|
||||
"uploads_domain_enabled",
|
||||
];
|
||||
|
||||
for (const featureFlag of FEATURE_FLAGS) {
|
||||
test(`Feature flag '${featureFlag}' is enabled if enabled in the API response`, async (t) => {
|
||||
await withTmpDir(async (tmpDir) => {
|
||||
setupActionsVars(tmpDir, tmpDir);
|
||||
|
||||
const loggedMessages = [];
|
||||
const featureFlags = new GitHubFeatureFlags(
|
||||
{ type: GitHubVariant.DOTCOM },
|
||||
testApiDetails,
|
||||
getRecordingLogger(loggedMessages)
|
||||
);
|
||||
|
||||
const expectedFeatureFlags = {};
|
||||
for (const f of FEATURE_FLAGS) {
|
||||
expectedFeatureFlags[f] = false;
|
||||
}
|
||||
expectedFeatureFlags[featureFlag] = true;
|
||||
mockHttpRequests(200, expectedFeatureFlags);
|
||||
|
||||
const actualFeatureFlags = {
|
||||
database_uploads_enabled:
|
||||
await featureFlags.getDatabaseUploadsEnabled(),
|
||||
ml_powered_queries_enabled:
|
||||
await featureFlags.getMlPoweredQueriesEnabled(),
|
||||
uploads_domain_enabled: await featureFlags.getUploadsDomainEnabled(),
|
||||
};
|
||||
|
||||
t.deepEqual(actualFeatureFlags, expectedFeatureFlags);
|
||||
});
|
||||
});
|
||||
}
|
||||
90
src/feature-flags.ts
Normal file
90
src/feature-flags.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { getApiClient, GitHubApiDetails } from "./api-client";
|
||||
import { Logger } from "./logging";
|
||||
import { parseRepositoryNwo } from "./repository";
|
||||
import * as util from "./util";
|
||||
|
||||
interface FeatureFlags {
|
||||
getDatabaseUploadsEnabled(): Promise<boolean>;
|
||||
getMlPoweredQueriesEnabled(): Promise<boolean>;
|
||||
getUploadsDomainEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A response from the GitHub API that contains feature flag enablement information for the CodeQL
|
||||
* Action.
|
||||
*
|
||||
* It maps feature flags to whether they are enabled or not.
|
||||
*/
|
||||
type FeatureFlagsApiResponse = { [flagName: string]: boolean };
|
||||
|
||||
export class GitHubFeatureFlags implements FeatureFlags {
|
||||
private cachedApiResponse: FeatureFlagsApiResponse | undefined;
|
||||
|
||||
constructor(
|
||||
private gitHubVersion: util.GitHubVersion,
|
||||
private apiDetails: GitHubApiDetails,
|
||||
private logger: Logger
|
||||
) {}
|
||||
|
||||
getDatabaseUploadsEnabled(): Promise<boolean> {
|
||||
return this.getFeatureFlag("database_uploads_enabled");
|
||||
}
|
||||
|
||||
getMlPoweredQueriesEnabled(): Promise<boolean> {
|
||||
return this.getFeatureFlag("ml_powered_queries_enabled");
|
||||
}
|
||||
|
||||
getUploadsDomainEnabled(): Promise<boolean> {
|
||||
return this.getFeatureFlag("uploads_domain_enabled");
|
||||
}
|
||||
|
||||
async preloadFeatureFlags(): Promise<void> {
|
||||
await this.getApiResponse();
|
||||
}
|
||||
|
||||
private async getFeatureFlag(name: string): Promise<boolean> {
|
||||
const response = (await this.getApiResponse())[name];
|
||||
if (response === undefined) {
|
||||
this.logger.debug(
|
||||
`Feature flag '${name}' undefined in API response, considering it disabled.`
|
||||
);
|
||||
}
|
||||
return response || false;
|
||||
}
|
||||
|
||||
private async getApiResponse(): Promise<FeatureFlagsApiResponse> {
|
||||
const loadApiResponse = async () => {
|
||||
// Do nothing when not running against github.com
|
||||
if (this.gitHubVersion.type !== util.GitHubVariant.DOTCOM) {
|
||||
this.logger.debug(
|
||||
"Not running against github.com. Disabling all feature flags."
|
||||
);
|
||||
return {};
|
||||
}
|
||||
const client = getApiClient(this.apiDetails);
|
||||
const repositoryNwo = parseRepositoryNwo(
|
||||
util.getRequiredEnvParam("GITHUB_REPOSITORY")
|
||||
);
|
||||
try {
|
||||
const response = await client.request(
|
||||
"GET /repos/:owner/:repo/code-scanning/codeql-action/features",
|
||||
{
|
||||
owner: repositoryNwo.owner,
|
||||
repo: repositoryNwo.repo,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.logger.info(
|
||||
`Disabling all feature flags due to unknown error: ${e}`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const apiResponse = this.cachedApiResponse || (await loadApiResponse());
|
||||
this.cachedApiResponse = apiResponse;
|
||||
return apiResponse;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { TestInterface } from "ava";
|
|||
import * as sinon from "sinon";
|
||||
|
||||
import * as CodeQL from "./codeql";
|
||||
import { Logger } from "./logging";
|
||||
|
||||
type TestContext = {
|
||||
stdoutWrite: any;
|
||||
|
|
@ -89,3 +90,32 @@ export function setupActionsVars(tempDir: string, toolsDir: string) {
|
|||
process.env["RUNNER_TEMP"] = tempDir;
|
||||
process.env["RUNNER_TOOL_CACHE"] = toolsDir;
|
||||
}
|
||||
|
||||
export interface LoggedMessage {
|
||||
type: "debug" | "info" | "warning" | "error";
|
||||
message: string | Error;
|
||||
}
|
||||
|
||||
export function getRecordingLogger(messages: LoggedMessage[]): Logger {
|
||||
return {
|
||||
debug: (message: string) => {
|
||||
messages.push({ type: "debug", message });
|
||||
console.debug(message);
|
||||
},
|
||||
info: (message: string) => {
|
||||
messages.push({ type: "info", message });
|
||||
console.info(message);
|
||||
},
|
||||
warning: (message: string | Error) => {
|
||||
messages.push({ type: "warning", message });
|
||||
console.warn(message);
|
||||
},
|
||||
error: (message: string | Error) => {
|
||||
messages.push({ type: "error", message });
|
||||
console.error(message);
|
||||
},
|
||||
isDebug: () => true,
|
||||
startGroup: () => undefined,
|
||||
endGroup: () => undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue