diff --git a/.buildkite/pipeline-utils/affected-packages/README.md b/.buildkite/pipeline-utils/affected-packages/README.md index 9f9d33bc77ddc..0255754e54e1e 100644 --- a/.buildkite/pipeline-utils/affected-packages/README.md +++ b/.buildkite/pipeline-utils/affected-packages/README.md @@ -69,7 +69,6 @@ const affectedPackages = await getAffectedPackages( { strategy: 'git', // default, can also be 'moon' includeDownstream: true, - logging: false, ignorePatterns: ['**/*.md', 'docs/**'], ignoreUncategorizedChanges: false, } @@ -109,7 +108,6 @@ const filteredFiles = filterFilesByPackages( |------------------------------------|---------------------------------|--------------|----------------------| | `AFFECTED_STRATEGY` | `git`, `moon` | `git` | `git` | | `AFFECTED_DOWNSTREAM` | `true`, `false` | `false` | `true` | -| `AFFECTED_LOGGING` | `true`, `false` | `false` | `true` | | `AFFECTED_IGNORE` | comma-separated globs | — | — | | `AFFECTED_IGNORE_UNCATEGORIZED_CHANGES` | `true`, `false` | `false` | `false` | | `GITHUB_PR_MERGE_BASE` | any git ref | `origin/main`| — | diff --git a/.buildkite/pipeline-utils/affected-packages/const.ts b/.buildkite/pipeline-utils/affected-packages/const.ts new file mode 100644 index 0000000000000..532395190e2ce --- /dev/null +++ b/.buildkite/pipeline-utils/affected-packages/const.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export const UNCATEGORIZED_MODULE_ID = '[uncategorized]'; + +export const SELECTIVE_TESTS_LABEL = 'ci:use-selective-testing'; + +// Changes here skip affected-package filtering for Jest (full run). +// Keep narrow: global test harness, transforms, CI selection. +export const CRITICAL_FILES_JEST_UNIT_TESTS = [ + 'scripts/jest.js', + 'scripts/jest_all.js', + 'package.json', + 'yarn.lock', + 'tsconfig.json', + '.node-version', + '.nvmrc', + 'src/setup_node_env/**/*', + 'packages/kbn-babel-preset/**/*', + 'src/platform/packages/shared/kbn-repo-info/**/*', + 'src/platform/packages/shared/kbn-test/**/*', + 'src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/**/*', + 'src/platform/packages/shared/react/kibana_mount/test_helpers/react_mount_serializer.ts', + 'src/platform/packages/private/kbn-jest-serializers/**/*', + '.buildkite/pipeline-utils/affected-packages/**/*.{ts,js,sh}', + '.buildkite/pipeline-utils/ci-stats/**/*.{ts,js}', +]; + +export const CRITICAL_FILES_JEST_INTEGRATION_TESTS = [ + 'scripts/jest_integration.js', + 'scripts/jest_all.js', + 'package.json', + 'yarn.lock', + 'tsconfig.json', + '.node-version', + '.nvmrc', + 'src/setup_node_env/**/*', + 'packages/kbn-babel-preset/**/*', + 'src/platform/packages/shared/kbn-repo-info/**/*', + 'src/platform/packages/shared/kbn-test/**/*', + 'src/platform/packages/private/kbn-scout-reporting/src/reporting/jest/**/*', + 'src/platform/packages/shared/react/kibana_mount/test_helpers/react_mount_serializer.ts', + '.buildkite/pipeline-utils/affected-packages/**/*.{ts,js,sh}', + '.buildkite/pipeline-utils/ci-stats/**/*.{ts,js}', +]; diff --git a/.buildkite/pipeline-utils/affected-packages/index.ts b/.buildkite/pipeline-utils/affected-packages/index.ts index 181fb28ae42e1..d9b32da5b3211 100644 --- a/.buildkite/pipeline-utils/affected-packages/index.ts +++ b/.buildkite/pipeline-utils/affected-packages/index.ts @@ -11,13 +11,16 @@ import { findModuleForPath } from './module_lookup'; import { getAffectedModulesGit } from './strategy_git'; import { getAffectedProjectsMoon } from './strategy_moon'; +export * from './const'; +export * from './utils'; +export { listChangedFiles } from './strategy_git'; + export interface AffectedPackagesConfig { - strategy: 'git' | 'moon'; - includeDownstream: boolean; - logging: boolean; + strategy?: 'git' | 'moon'; + includeDownstream?: boolean; /** Glob patterns for changed files to exclude before module resolution (git strategy only). */ - ignorePatterns: string[]; - ignoreUncategorizedChanges: boolean; + ignorePatterns?: string[]; + ignoreUncategorizedChanges?: boolean; } /** @@ -26,12 +29,19 @@ export interface AffectedPackagesConfig { */ export async function getAffectedPackages( mergeBase: string | undefined, - config: AffectedPackagesConfig = getConfigFromEnv() + configArgs: AffectedPackagesConfig = getConfigFromEnv() ): Promise> { if (!mergeBase) { throw new Error('No merge base found'); } + const config = { + strategy: configArgs.strategy ?? 'git', + includeDownstream: configArgs.includeDownstream ?? false, + ignorePatterns: configArgs.ignorePatterns ?? [], + ignoreUncategorizedChanges: configArgs.ignoreUncategorizedChanges ?? false, + }; + try { const affectedPackages = config.strategy === 'git' @@ -82,7 +92,6 @@ function getConfigFromEnv(): AffectedPackagesConfig { } const strategy = rawStrategy; const includeDownstream = process.env.AFFECTED_DOWNSTREAM !== 'false'; - const logging = process.env.AFFECTED_LOGGING !== 'false'; const ignorePatterns = (process.env.AFFECTED_IGNORE || '') .split(',') .map((p) => p.trim()) @@ -90,5 +99,5 @@ function getConfigFromEnv(): AffectedPackagesConfig { const ignoreUncategorizedChanges = process.env.AFFECTED_IGNORE_UNCATEGORIZED_CHANGES !== 'false'; - return { strategy, includeDownstream, logging, ignorePatterns, ignoreUncategorizedChanges }; + return { strategy, includeDownstream, ignorePatterns, ignoreUncategorizedChanges }; } diff --git a/.buildkite/pipeline-utils/affected-packages/list_affected b/.buildkite/pipeline-utils/affected-packages/list_affected index 74c610a423bfd..4e7b0ba9bb181 100755 --- a/.buildkite/pipeline-utils/affected-packages/list_affected +++ b/.buildkite/pipeline-utils/affected-packages/list_affected @@ -85,7 +85,6 @@ async function main() { const affectedPackages = await getAffectedPackages(mergeBase, { strategy, includeDownstream, - logging: false, ignorePatterns, ignoreUncategorizedChanges, }); diff --git a/.buildkite/pipeline-utils/affected-packages/module_lookup.test.ts b/.buildkite/pipeline-utils/affected-packages/module_lookup.test.ts index 01705bd4478e7..b3234f68106ae 100644 --- a/.buildkite/pipeline-utils/affected-packages/module_lookup.test.ts +++ b/.buildkite/pipeline-utils/affected-packages/module_lookup.test.ts @@ -24,10 +24,10 @@ import { getModuleDependencies, buildModuleDownstreamGraph, resetModuleLookupCache, - UNCATEGORIZED_MODULE_ID, } from './module_lookup'; import { getAffectedModulesGit } from './strategy_git'; import { filterIgnoredFiles } from './utils'; +import { UNCATEGORIZED_MODULE_ID } from './const'; function git(cwd: string, command: string): string { return execSync(`git ${command}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/.buildkite/pipeline-utils/affected-packages/module_lookup.ts b/.buildkite/pipeline-utils/affected-packages/module_lookup.ts index 5f5a5ad523984..5225e0c3a5ed9 100644 --- a/.buildkite/pipeline-utils/affected-packages/module_lookup.ts +++ b/.buildkite/pipeline-utils/affected-packages/module_lookup.ts @@ -12,8 +12,7 @@ import * as fs from 'fs'; import { execSync } from 'child_process'; import * as JSON5 from 'json5'; import { getKibanaDir } from '../utils'; - -export const UNCATEGORIZED_MODULE_ID = '[uncategorized]'; +import { UNCATEGORIZED_MODULE_ID } from './const'; export interface ModuleLookup { /** diff --git a/.buildkite/pipeline-utils/affected-packages/strategy_git.ts b/.buildkite/pipeline-utils/affected-packages/strategy_git.ts index a89644ae08171..a3cba0691bdcb 100644 --- a/.buildkite/pipeline-utils/affected-packages/strategy_git.ts +++ b/.buildkite/pipeline-utils/affected-packages/strategy_git.ts @@ -9,11 +9,8 @@ import { execSync } from 'child_process'; import { getKibanaDir } from '../utils'; -import { - findModuleForPath, - buildModuleDownstreamGraph, - UNCATEGORIZED_MODULE_ID, -} from './module_lookup'; +import { findModuleForPath, buildModuleDownstreamGraph } from './module_lookup'; +import { UNCATEGORIZED_MODULE_ID } from './const'; import { filterIgnoredFiles } from './utils'; const isCI = !!process.env.CI?.match(/^(1|true)$/i); @@ -50,7 +47,14 @@ export function getAffectedModulesGit({ return includeDownstream ? getDownstreamDependents(directlyAffected) : directlyAffected; } -function listChangedFiles({ mergeBase, commit }: { mergeBase: string; commit: string }): string[] { +/** Paths changed from `git merge-base mergeBase HEAD` to `commit` (plus local untracked when not CI). */ +export function listChangedFiles({ + mergeBase, + commit, +}: { + mergeBase: string; + commit: string; +}): string[] { const execOptions = { cwd: getKibanaDir(), encoding: 'utf8' as const, diff --git a/.buildkite/pipeline-utils/affected-packages/strategy_moon.ts b/.buildkite/pipeline-utils/affected-packages/strategy_moon.ts index 37e2f7e172ba0..32f9147969bf1 100644 --- a/.buildkite/pipeline-utils/affected-packages/strategy_moon.ts +++ b/.buildkite/pipeline-utils/affected-packages/strategy_moon.ts @@ -22,7 +22,7 @@ export function getAffectedProjectsMoon( const output = execSync(command, { cwd: REPO_ROOT, encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, // 10MB buffer + maxBuffer: 30 * 1024 * 1024, // 30MB buffer env: { ...process.env, MOON_BASE: mergeBase, diff --git a/.buildkite/pipeline-utils/affected-packages/utils.ts b/.buildkite/pipeline-utils/affected-packages/utils.ts index e8b128410548a..5bc3323e69652 100644 --- a/.buildkite/pipeline-utils/affected-packages/utils.ts +++ b/.buildkite/pipeline-utils/affected-packages/utils.ts @@ -19,3 +19,12 @@ export function filterIgnoredFiles(files: string[], patterns: string[]): string[ } return files.filter((file) => !patterns.some((p) => minimatch(file, p, { dot: true }))); } + +/** + * Returns true when any pattern matches any file in the list + */ +export function touchedCriticalFiles(files: string[], criticalFiles: string[]): boolean { + return files.some((file) => + criticalFiles.some((criticalFile) => minimatch(file, criticalFile, { dot: true })) + ); +} diff --git a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts index dd4f0f7caad51..ef1d262f8c844 100644 --- a/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts +++ b/.buildkite/pipeline-utils/ci-stats/pick_test_group_run_order.ts @@ -19,207 +19,29 @@ import { CiStatsClient, TestGroupRunOrderResponse } from './client'; import DISABLED_JEST_CONFIGS from '../../disabled_jest_configs.json'; import { serverless, stateful } from '../../ftr_configs_manifests.json'; -import { collectEnvFromLabels, expandAgentQueue } from '#pipeline-utils'; +import { + getAffectedPackages, + listChangedFiles, + filterFilesByPackages, + SELECTIVE_TESTS_LABEL, + CRITICAL_FILES_JEST_UNIT_TESTS, + touchedCriticalFiles, + CRITICAL_FILES_JEST_INTEGRATION_TESTS, +} from '../affected-packages'; +import { collectEnvFromLabels, expandAgentQueue, getRequiredEnv } from '#pipeline-utils'; + +// TODO: this is always false on on-merge, when switching to enable this by default, check if this is a PR +const USE_SELECTIVE_TESTING = process.env.GITHUB_PR_LABELS?.includes(SELECTIVE_TESTS_LABEL); const ALL_FTR_MANIFEST_REL_PATHS = serverless.concat(stateful); type RunGroup = TestGroupRunOrderResponse['types'][0]; - -const getRequiredEnv = (name: string) => { - const value = process.env[name]; - if (typeof value !== 'string' || !value) { - throw new Error(`Missing required environment variable "${name}"`); - } - return value; -}; - -function getRunGroups(bk: BuildkiteClient, allTypes: RunGroup[], typeName: string): RunGroup[] { - const types = allTypes.filter((t) => t.type === typeName); - if (!types.length) { - throw new Error(`missing test group run order for group [${typeName}]`); - } - - const uniqueTooLongMin = [ - ...new Set( - types.map((t) => t.tooLongMin).filter((value): value is number => typeof value === 'number') - ), - ]; - const tooLongThresholdLabel = - uniqueTooLongMin.length > 0 - ? `configured warning threshold${ - uniqueTooLongMin.length === 1 ? ` of ${uniqueTooLongMin[0]} minutes` : '' - }` - : 'maximum amount of time desired for a single CI job'; - - const misses = types.flatMap((t) => t.namesWithoutDurations); - if (misses.length > 0) { - bk.setAnnotation( - `test-group-missing-durations:${typeName}`, - 'warning', - [ - misses.length === 1 - ? `The following "${typeName}" config doesn't have a recorded time in ci-stats so the automatically-determined test groups might be a little unbalanced.` - : `The following "${typeName}" configs don't have recorded times in ci-stats so the automatically-determined test groups might be a little unbalanced.`, - misses.length === 1 - ? `If this is a new config then this warning can be ignored as times will be reported soon.` - : `If these are new configs then this warning can be ignored as times will be reported soon.`, - misses.length === 1 - ? `The other possibility is that there aren't any tests in this config, so times are never reported.` - : `The other possibility is that there aren't any tests in these configs, so times are never reported.`, - 'Empty test configs should be removed', - '', - ...misses.map((n) => ` - ${n}`), - ].join('\n') - ); - } - - const tooLongs = types.flatMap((t) => t.tooLong ?? []); - if (tooLongs.length > 0) { - bk.setAnnotation( - `test-group-too-long:${typeName}`, - 'warning', - [ - tooLongs.length === 1 - ? `The following "${typeName}" config has a duration that exceeds the ${tooLongThresholdLabel}. ` + - `This is not an error, and if you don't own this config then you can ignore this warning. ` + - `If you own this config please split it up ASAP and ask Operations if you have questions about how to do that.` - : `The following "${typeName}" configs have durations that exceed the ${tooLongThresholdLabel}. ` + - `This is not an error, and if you don't own any of these configs then you can ignore this warning.` + - `If you own any of these configs please split them up ASAP and ask Operations if you have questions about how to do that.`, - '', - ...tooLongs.map(({ config, durationMin }) => ` - ${config}: ${durationMin} minutes`), - ].join('\n') - ); - } - - return types; -} - -function getRunGroup(bk: BuildkiteClient, allTypes: RunGroup[], typeName: string): RunGroup { - const groups = getRunGroups(bk, allTypes, typeName); - if (groups.length !== 1) { - throw new Error(`expected to find exactly 1 "${typeName}" run group`); - } - return groups[0]; -} - -function getTrackedBranch(): string { - let pkg; - try { - pkg = JSON.parse(Fs.readFileSync('package.json', 'utf8')); - } catch (_) { - const error = _ instanceof Error ? _ : new Error(`${_} thrown`); - throw new Error(`unable to read kibana's package.json file: ${error.message}`); - } - - const branch = pkg.branch; - if (typeof branch !== 'string') { - throw new Error('missing `branch` field from package.json file'); - } - - return branch; -} - -function isObj(x: unknown): x is Record { - return typeof x === 'object' && x !== null; -} - interface FtrConfigsManifest { defaultQueue?: string; disabled?: string[]; enabled?: Array; } -function getEnabledFtrConfigs(patterns?: string[], solutions?: string[]) { - const configs: { - enabled: Array; - defaultQueue: string | undefined; - } = { enabled: [], defaultQueue: undefined }; - const uniqueQueues = new Set(); - - const mappedSolutions = solutions?.map((s) => (s === 'observability' ? 'oblt' : s)); - for (const manifestRelPath of ALL_FTR_MANIFEST_REL_PATHS) { - if ( - mappedSolutions && - !( - mappedSolutions.some((s) => manifestRelPath.includes(`ftr_${s}_`)) || - // When applying the solution filter, still allow platform tests - manifestRelPath.includes('ftr_platform_') || - manifestRelPath.includes('ftr_base_') - ) - ) { - continue; - } - try { - const ymlData = loadYaml(Fs.readFileSync(manifestRelPath, 'utf8')); - if (!isObj(ymlData)) { - throw new Error('expected yaml file to parse to an object'); - } - const manifest = ymlData as FtrConfigsManifest; - - configs.enabled.push(...(manifest?.enabled ?? [])); - if (manifest.defaultQueue) { - uniqueQueues.add(manifest.defaultQueue); - } - } catch (_) { - const error = _ instanceof Error ? _ : new Error(`${_} thrown`); - throw new Error(`unable to parse ${manifestRelPath} file: ${error.message}`); - } - } - - try { - if (configs.enabled.length === 0) { - throw new Error('expected yaml files to have at least 1 "enabled" key'); - } - if (uniqueQueues.size !== 1) { - throw Error( - `FTR manifest yml files should define the same 'defaultQueue', but found different ones: ${[ - ...uniqueQueues, - ].join(' ')}` - ); - } - configs.defaultQueue = uniqueQueues.values().next().value; - - if ( - !Array.isArray(configs.enabled) || - !configs.enabled.every( - (p): p is string | { [configPath: string]: { queue: string } } => - typeof p === 'string' || - (isObj(p) && Object.values(p).every((v) => isObj(v) && typeof v.queue === 'string')) - ) - ) { - throw new Error(`expected "enabled" value to be an array of strings or objects shaped as:\n - - {configPath}: - queue: {queueName}`); - } - if (typeof configs.defaultQueue !== 'string') { - throw new Error('expected yaml file to have a string "defaultQueue" key'); - } - - const defaultQueue = configs.defaultQueue; - const ftrConfigsByQueue = new Map(); - for (const enabled of configs.enabled) { - const path = typeof enabled === 'string' ? enabled : Object.keys(enabled)[0]; - const queue = isObj(enabled) ? enabled[path].queue : defaultQueue; - - if (patterns && !patterns.some((pattern) => minimatch(path, pattern))) { - continue; - } - - const group = ftrConfigsByQueue.get(queue); - if (group) { - group.push(path); - } else { - ftrConfigsByQueue.set(queue, [path]); - } - } - return { defaultQueue, ftrConfigsByQueue }; - } catch (_) { - const error = _ instanceof Error ? _ : new Error(`${_} thrown`); - throw new Error(`unable to collect enabled FTR configs: ${error.message}`); - } -} - export async function pickTestGroupRunOrder() { const bk = new BuildkiteClient(); const ciStats = new CiStatsClient(); @@ -350,7 +172,7 @@ export async function pickTestGroupRunOrder() { ); }; - const jestUnitConfigs = LIMIT_CONFIG_TYPE.includes('unit') + let jestUnitConfigs = LIMIT_CONFIG_TYPE.includes('unit') ? globby.sync(getJestConfigGlobs(['**/jest.config.js', '!**/__fixtures__/**']), { cwd: process.cwd(), absolute: false, @@ -358,7 +180,7 @@ export async function pickTestGroupRunOrder() { }) : []; - const jestIntegrationConfigs = LIMIT_CONFIG_TYPE.includes('integration') + let jestIntegrationConfigs = LIMIT_CONFIG_TYPE.includes('integration') ? globby.sync(getJestConfigGlobs(['**/jest.integration.config.js', '!**/__fixtures__/**']), { cwd: process.cwd(), absolute: false, @@ -366,6 +188,13 @@ export async function pickTestGroupRunOrder() { }) : []; + if (USE_SELECTIVE_TESTING && process.env.GITHUB_PR_MERGE_BASE) { + const { filteredJestUnitConfigs, filteredJestIntegrationConfigs } = + await filterConfigsByAffectedPackages(jestUnitConfigs, jestIntegrationConfigs); + jestUnitConfigs = filteredJestUnitConfigs; + jestIntegrationConfigs = filteredJestIntegrationConfigs; + } + if (!ftrConfigsByQueue.size && !jestUnitConfigs.length && !jestIntegrationConfigs.length) { throw new Error('unable to find any unit, integration, or FTR configs'); } @@ -626,3 +455,236 @@ export async function pickTestGroupRunOrder() { // upload the step definitions to Buildkite bk.uploadSteps(steps); } + +function getRunGroups(bk: BuildkiteClient, allTypes: RunGroup[], typeName: string): RunGroup[] { + const types = allTypes.filter((t) => t.type === typeName); + if (!types.length) { + throw new Error(`missing test group run order for group [${typeName}]`); + } + + const uniqueTooLongMin = [ + ...new Set( + types.map((t) => t.tooLongMin).filter((value): value is number => typeof value === 'number') + ), + ]; + const tooLongThresholdLabel = + uniqueTooLongMin.length > 0 + ? `configured warning threshold${ + uniqueTooLongMin.length === 1 ? ` of ${uniqueTooLongMin[0]} minutes` : '' + }` + : 'maximum amount of time desired for a single CI job'; + + const misses = types.flatMap((t) => t.namesWithoutDurations); + if (misses.length > 0) { + bk.setAnnotation( + `test-group-missing-durations:${typeName}`, + 'warning', + [ + misses.length === 1 + ? `The following "${typeName}" config doesn't have a recorded time in ci-stats so the automatically-determined test groups might be a little unbalanced.` + : `The following "${typeName}" configs don't have recorded times in ci-stats so the automatically-determined test groups might be a little unbalanced.`, + misses.length === 1 + ? `If this is a new config then this warning can be ignored as times will be reported soon.` + : `If these are new configs then this warning can be ignored as times will be reported soon.`, + misses.length === 1 + ? `The other possibility is that there aren't any tests in this config, so times are never reported.` + : `The other possibility is that there aren't any tests in these configs, so times are never reported.`, + 'Empty test configs should be removed', + '', + ...misses.map((n) => ` - ${n}`), + ].join('\n') + ); + } + + const tooLongs = types.flatMap((t) => t.tooLong ?? []); + if (tooLongs.length > 0) { + bk.setAnnotation( + `test-group-too-long:${typeName}`, + 'warning', + [ + tooLongs.length === 1 + ? `The following "${typeName}" config has a duration that exceeds the ${tooLongThresholdLabel}. ` + + `This is not an error, and if you don't own this config then you can ignore this warning. ` + + `If you own this config please split it up ASAP and ask Operations if you have questions about how to do that.` + : `The following "${typeName}" configs have durations that exceed the ${tooLongThresholdLabel}. ` + + `This is not an error, and if you don't own any of these configs then you can ignore this warning.` + + `If you own any of these configs please split them up ASAP and ask Operations if you have questions about how to do that.`, + '', + ...tooLongs.map(({ config, durationMin }) => ` - ${config}: ${durationMin} minutes`), + ].join('\n') + ); + } + + return types; +} + +function getRunGroup(bk: BuildkiteClient, allTypes: RunGroup[], typeName: string): RunGroup { + const groups = getRunGroups(bk, allTypes, typeName); + if (groups.length !== 1) { + throw new Error(`expected to find exactly 1 "${typeName}" run group`); + } + return groups[0]; +} + +function getTrackedBranch(): string { + let pkg; + try { + pkg = JSON.parse(Fs.readFileSync('package.json', 'utf8')); + } catch (_) { + const error = _ instanceof Error ? _ : new Error(`${_} thrown`); + throw new Error(`unable to read kibana's package.json file: ${error.message}`); + } + + const branch = pkg.branch; + if (typeof branch !== 'string') { + throw new Error('missing `branch` field from package.json file'); + } + + return branch; +} + +function isObj(x: unknown): x is Record { + return typeof x === 'object' && x !== null; +} + +function getEnabledFtrConfigs(patterns?: string[], solutions?: string[]) { + const configs: { + enabled: Array; + defaultQueue: string | undefined; + } = { enabled: [], defaultQueue: undefined }; + const uniqueQueues = new Set(); + + const mappedSolutions = solutions?.map((s) => (s === 'observability' ? 'oblt' : s)); + for (const manifestRelPath of ALL_FTR_MANIFEST_REL_PATHS) { + if ( + mappedSolutions && + !( + mappedSolutions.some((s) => manifestRelPath.includes(`ftr_${s}_`)) || + // When applying the solution filter, still allow platform tests + manifestRelPath.includes('ftr_platform_') || + manifestRelPath.includes('ftr_base_') + ) + ) { + continue; + } + try { + const ymlData = loadYaml(Fs.readFileSync(manifestRelPath, 'utf8')); + if (!isObj(ymlData)) { + throw new Error('expected yaml file to parse to an object'); + } + const manifest = ymlData as FtrConfigsManifest; + + configs.enabled.push(...(manifest?.enabled ?? [])); + if (manifest.defaultQueue) { + uniqueQueues.add(manifest.defaultQueue); + } + } catch (_) { + const error = _ instanceof Error ? _ : new Error(`${_} thrown`); + throw new Error(`unable to parse ${manifestRelPath} file: ${error.message}`); + } + } + + try { + if (configs.enabled.length === 0) { + throw new Error('expected yaml files to have at least 1 "enabled" key'); + } + if (uniqueQueues.size !== 1) { + throw Error( + `FTR manifest yml files should define the same 'defaultQueue', but found different ones: ${[ + ...uniqueQueues, + ].join(' ')}` + ); + } + configs.defaultQueue = uniqueQueues.values().next().value; + + if ( + !Array.isArray(configs.enabled) || + !configs.enabled.every( + (p): p is string | { [configPath: string]: { queue: string } } => + typeof p === 'string' || + (isObj(p) && Object.values(p).every((v) => isObj(v) && typeof v.queue === 'string')) + ) + ) { + throw new Error(`expected "enabled" value to be an array of strings or objects shaped as:\n + - {configPath}: + queue: {queueName}`); + } + if (typeof configs.defaultQueue !== 'string') { + throw new Error('expected yaml file to have a string "defaultQueue" key'); + } + + const defaultQueue = configs.defaultQueue; + const ftrConfigsByQueue = new Map(); + for (const enabled of configs.enabled) { + const path = typeof enabled === 'string' ? enabled : Object.keys(enabled)[0]; + const queue = isObj(enabled) ? enabled[path].queue : defaultQueue; + + if (patterns && !patterns.some((pattern) => minimatch(path, pattern))) { + continue; + } + + const group = ftrConfigsByQueue.get(queue); + if (group) { + group.push(path); + } else { + ftrConfigsByQueue.set(queue, [path]); + } + } + return { defaultQueue, ftrConfigsByQueue }; + } catch (_) { + const error = _ instanceof Error ? _ : new Error(`${_} thrown`); + throw new Error(`unable to collect enabled FTR configs: ${error.message}`); + } +} + +async function filterConfigsByAffectedPackages( + jestUnitConfigs: string[], + jestIntegrationConfigs: string[] +) { + const mergeBase = process.env.GITHUB_PR_MERGE_BASE!; + const affectedPackages = await getAffectedPackages(mergeBase, { + strategy: 'git', + includeDownstream: true, + ignorePatterns: [], // might want to exclude metadata/text changes in the future + ignoreUncategorizedChanges: true, + }).catch((error) => { + console.error('Error getting affected packages', error); + return null; + }); + + const shouldFilterByAffected = Boolean(affectedPackages); + if (!shouldFilterByAffected) { + console.log('Not filtering Jest unit/integration tests because no affected packages found'); + return { + filteredJestUnitConfigs: jestUnitConfigs, + filteredJestIntegrationConfigs: jestIntegrationConfigs, + }; + } + + const prChangedFiles = listChangedFiles({ mergeBase, commit: 'HEAD' }); + console.log('Filtering Jest unit/integration tests for affected packages:', affectedPackages); + + let filteredJestUnitConfigs = jestUnitConfigs; + let filteredJestIntegrationConfigs = jestIntegrationConfigs; + if (!touchedCriticalFiles(prChangedFiles, CRITICAL_FILES_JEST_UNIT_TESTS)) { + filteredJestUnitConfigs = filterFilesByPackages(jestUnitConfigs, affectedPackages); + console.log( + `Filtering Jest unit tests: ${jestUnitConfigs.length} -> ${filteredJestUnitConfigs.length}` + ); + } else { + console.log('Not filtering Jest unit tests because critical files changed'); + } + + if (!touchedCriticalFiles(prChangedFiles, CRITICAL_FILES_JEST_INTEGRATION_TESTS)) { + filteredJestIntegrationConfigs = filterFilesByPackages( + jestIntegrationConfigs, + affectedPackages + ); + console.log( + `Filtering Jest integration tests: ${jestIntegrationConfigs.length} -> ${filteredJestIntegrationConfigs.length}` + ); + } else { + console.log('Not filtering Jest integration tests because critical files changed'); + } + return { filteredJestUnitConfigs, filteredJestIntegrationConfigs }; +}