diff --git a/.changeset/eight-needles-swim.md b/.changeset/eight-needles-swim.md new file mode 100644 index 0000000000..edb9cab1f5 --- /dev/null +++ b/.changeset/eight-needles-swim.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Package paths provided to `api-reports` and OpenAPI commands will now match any path within the target package. diff --git a/.changeset/happy-cycles-retire.md b/.changeset/happy-cycles-retire.md new file mode 100644 index 0000000000..a5cb543a86 --- /dev/null +++ b/.changeset/happy-cycles-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Added `--include ` and `--exclude ` options for `api-reports` command that work based on package names. diff --git a/packages/repo-tools/cli-report.md b/packages/repo-tools/cli-report.md index 857c705006..b361bac02f 100644 --- a/packages/repo-tools/cli-report.md +++ b/packages/repo-tools/cli-report.md @@ -28,6 +28,8 @@ Options: --ci --tsc --docs + --include + --exclude -a, --allow-warnings --allow-all-warnings -o, --omit-messages diff --git a/packages/repo-tools/package.json b/packages/repo-tools/package.json index 5eb85378de..8d8a14a81b 100644 --- a/packages/repo-tools/package.json +++ b/packages/repo-tools/package.json @@ -32,6 +32,7 @@ "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", "@backstage/cli-common": "workspace:^", + "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "@manypkg/get-packages": "^1.1.3", "@microsoft/api-documenter": "^7.19.27", diff --git a/packages/repo-tools/src/commands/api-reports/api-extractor.ts b/packages/repo-tools/src/commands/api-reports/api-extractor.ts index 8194ede7a4..13b6acb8fd 100644 --- a/packages/repo-tools/src/commands/api-reports/api-extractor.ts +++ b/packages/repo-tools/src/commands/api-reports/api-extractor.ts @@ -1161,7 +1161,7 @@ export async function buildDocs({ documenter.generateFiles(); } -export async function categorizePackageDirs(packageDirs: any[]) { +export async function categorizePackageDirs(packageDirs: string[]) { const dirs = packageDirs.slice(); const tsPackageDirs = new Array(); const cliPackageDirs = new Array(); diff --git a/packages/repo-tools/src/commands/api-reports/api-reports.test.ts b/packages/repo-tools/src/commands/api-reports/api-reports.test.ts index a5a8635b7f..ccb6981baa 100644 --- a/packages/repo-tools/src/commands/api-reports/api-reports.test.ts +++ b/packages/repo-tools/src/commands/api-reports/api-reports.test.ts @@ -27,6 +27,7 @@ import { import { buildApiReports } from './api-reports'; import { generateTypeDeclarations } from './generateTypeDeclarations'; +import { PackageGraph } from '@backstage/cli-node'; jest.mock('./generateTypeDeclarations'); // create mocks for the dependencies of the `buildApiReports` function @@ -52,6 +53,28 @@ jest jest.spyOn(projectPaths, 'resolveTargetRoot').mockImplementation((...path) => { return resolvePath(normalize('/root'), ...path); }); +jest.spyOn(PackageGraph, 'listTargetPackages').mockResolvedValue([ + { + dir: '/root/packages/package-a', + packageJson: { name: 'package-a', version: '0.0.0' }, + }, + { + dir: '/root/packages/package-b', + packageJson: { name: 'package-b', version: '0.0.0' }, + }, + { + dir: '/root/plugins/plugin-a', + packageJson: { name: 'plugin-a', version: '0.0.0' }, + }, + { + dir: '/root/plugins/plugin-b', + packageJson: { name: 'plugin-b', version: '0.0.0' }, + }, + { + dir: '/root/plugins/plugin-c', + packageJson: { name: 'plugin-c', version: '0.0.0' }, + }, +]); describe('buildApiReports', () => { beforeEach(() => { diff --git a/packages/repo-tools/src/commands/api-reports/api-reports.ts b/packages/repo-tools/src/commands/api-reports/api-reports.ts index b673689b5a..d9164578b0 100644 --- a/packages/repo-tools/src/commands/api-reports/api-reports.ts +++ b/packages/repo-tools/src/commands/api-reports/api-reports.ts @@ -22,7 +22,7 @@ import { runCliExtraction, buildDocs, } from './api-extractor'; -import { getMatchingWorkspacePaths, paths as cliPaths } from '../../lib/paths'; +import { paths as cliPaths, resolvePackagePaths } from '../../lib/paths'; import { generateTypeDeclarations } from './generateTypeDeclarations'; type Options = { @@ -48,7 +48,11 @@ export const buildApiReports = async (paths: string[] = [], opts: Options) => { const omitMessages = parseArrayOption(opts.omitMessages); const isAllPackages = !paths?.length; - const selectedPackageDirs = await getMatchingWorkspacePaths(paths); + const selectedPackageDirs = await resolvePackagePaths({ + paths, + include: opts.include, + exclude: opts.exclude, + }); if (isAllPackages && !isCiBuild && !isDocsBuild) { console.log(''); diff --git a/packages/repo-tools/src/commands/index.ts b/packages/repo-tools/src/commands/index.ts index 7aa7acef0b..87d3f7acde 100644 --- a/packages/repo-tools/src/commands/index.ts +++ b/packages/repo-tools/src/commands/index.ts @@ -24,6 +24,16 @@ export function registerCommands(program: Command) { .option('--ci', 'CI run checks that there is no changes on API reports') .option('--tsc', 'executes the tsc compilation before extracting the APIs') .option('--docs', 'generates the api documentation') + .option( + '--include ', + 'Only include packages matching the provided patterns', + (opt: string, opts: string[] = []) => [...opts, ...opt.split(',')], + ) + .option( + '--exclude ', + 'Exclude package matching the provided patterns', + (opt: string, opts: string[] = []) => [...opts, ...opt.split(',')], + ) .option( '-a, --allow-warnings ', 'continue processing packages after getting errors on selected packages Allows glob patterns and comma separated values (i.e. packages/core,plugins/core-*)', diff --git a/packages/repo-tools/src/commands/openapi/runner.ts b/packages/repo-tools/src/commands/openapi/runner.ts index 4b1b0167bf..baffb93808 100644 --- a/packages/repo-tools/src/commands/openapi/runner.ts +++ b/packages/repo-tools/src/commands/openapi/runner.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { getMatchingWorkspacePaths } from '../../lib/paths'; +import { resolvePackagePaths } from '../../lib/paths'; import pLimit from 'p-limit'; import { relative as relativePath } from 'path'; import { paths as cliPaths } from '../../lib/paths'; @@ -23,7 +23,7 @@ export async function runner( paths: string[], command: (dir: string) => Promise, ) { - const packages = await getMatchingWorkspacePaths(paths); + const packages = await resolvePackagePaths({ paths }); const limit = pLimit(5); const resultsList = await Promise.all( diff --git a/packages/repo-tools/src/lib/paths.test.ts b/packages/repo-tools/src/lib/paths.test.ts index 601ba636c3..bec93617ff 100644 --- a/packages/repo-tools/src/lib/paths.test.ts +++ b/packages/repo-tools/src/lib/paths.test.ts @@ -14,105 +14,76 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath, normalize } from 'path'; -import { resolvePackagePath, paths, findPackageDirs } from './paths'; +import { resolve as resolvePath } from 'path'; +import { resolvePackagePaths } from './paths'; -describe('paths', () => { - jest.spyOn(paths, 'targetRoot', 'get').mockReturnValue(normalize('/root')); - jest.spyOn(paths, 'resolveTargetRoot').mockImplementation((...path) => { - return resolvePath(normalize('/root'), ...path); +describe('resolvePackages', () => { + it('should return all packages', async () => { + const paths = await resolvePackagePaths(); + expect(paths.length).toBeGreaterThan(10); + expect(paths).toContain('packages/cli'); + expect(paths).toContain('packages/repo-tools'); }); - beforeEach(() => { - mockFs({ - [paths.targetRoot]: { - 'package.json': JSON.stringify({ name: 'test' }), - packages: { - 'package-a': { - 'package.json': '{}', - }, - 'package-b': { - 'package.json': '{}', - }, - 'package-c': {}, - 'README.md': 'Hello World', - }, - plugins: { - 'plugin-a': { - 'package.json': '{}', - }, - 'plugin-b': { - 'package.json': '{}', - }, - }, - }, - }); + it('should filter by path', async () => { + await expect( + resolvePackagePaths({ paths: ['packages/repo-tools'] }), + ).resolves.toEqual(['packages/repo-tools']); + + await expect( + resolvePackagePaths({ paths: [resolvePath('packages/repo-tools')] }), + ).resolves.toEqual(['packages/repo-tools']); + + await expect( + resolvePackagePaths({ + paths: [resolvePath('packages/repo-tools/package.json')], + }), + ).resolves.toEqual(['packages/repo-tools']); + await expect( + resolvePackagePaths({ + paths: ['packages/repo-tools/src/some/made/up/file.ts'], + }), + ).resolves.toEqual(['packages/repo-tools']); + await expect( + resolvePackagePaths({ + paths: ['packages/repo-tools/src/some/made/up/file.ts', 'packages/cli'], + }), + ).resolves.toEqual(['packages/cli', 'packages/repo-tools']); }); - afterEach(() => { - mockFs.restore(); + it('should filter with include', async () => { + const allPackages = await resolvePackagePaths(); + const pluginPackages = await resolvePackagePaths({ + include: ['plugins/*'], + }); + + expect(allPackages.length).toBeGreaterThan(10); + expect(pluginPackages.length).toBeGreaterThan(10); + expect(allPackages.length).toBeGreaterThan(pluginPackages.length); + + expect(pluginPackages).toContain('plugins/catalog'); + + await expect( + resolvePackagePaths({ include: ['packages/repo-t??ls'] }), + ).resolves.toEqual(['packages/repo-tools']); }); - describe('resolvePackagePath', () => { - it('should return undefined if the package does not exist or does not contain a package.json', async () => { - expect(await resolvePackagePath('packages/package-d')).toBeUndefined(); - expect(await resolvePackagePath('packages/package-c')).toBeUndefined(); - }); - it('should return the path to the package if it exists and has a package.json', async () => { - expect(await resolvePackagePath('packages/package-a')).toBe( - joinPath('packages', 'package-a'), - ); - expect(await resolvePackagePath('packages/package-b')).toBe( - joinPath('packages', 'package-b'), - ); - }); - it('should work with absolute paths', async () => { - expect(await resolvePackagePath('/root/packages/package-a')).toBe( - joinPath('packages', 'package-a'), - ); - }); - it('should return undefined if the pat is not a directory', async () => { - expect(await resolvePackagePath('packages/README.md')).toBeUndefined(); + it('should filter with exclude', async () => { + const nonPluginPackages = await resolvePackagePaths({ + exclude: ['plugins/*'], }); + + expect(nonPluginPackages).toContain('packages/app'); + expect(nonPluginPackages).not.toContain('plugins/catalog'); }); - describe('findPackageDirs', () => { - it('should return only the given packages', async () => { - expect(await findPackageDirs(['packages/package-a'])).toEqual([ - joinPath('packages', 'package-a'), - ]); - }); - it('should return only the given packages when using glob patterns', async () => { - expect(await findPackageDirs(['packages/*'])).toEqual([ - joinPath('packages', 'package-a'), - joinPath('packages', 'package-b'), - ]); - expect(await findPackageDirs(['packages/*', 'plugins/*'])).toEqual([ - joinPath('packages', 'package-a'), - joinPath('packages', 'package-b'), - joinPath('plugins', 'plugin-a'), - joinPath('plugins', 'plugin-b'), - ]); - }); - it('should return only the given packages when using absolute paths', async () => { - expect( - await findPackageDirs([ - '/root/packages/package-a', - '/root/plugins/plugin-b', - ]), - ).toEqual([ - joinPath('packages', 'package-a'), - joinPath('plugins', 'plugin-b'), - ]); - }); - it('should return only the given packages when using absolute paths with glob patterns', async () => { - expect( - await findPackageDirs(['/root/packages/*', '/root/plugins/*-a']), - ).toEqual([ - joinPath('packages', 'package-a'), - joinPath('packages', 'package-b'), - joinPath('plugins', 'plugin-a'), - ]); - }); + + it('should combine all options', async () => { + await expect( + resolvePackagePaths({ + paths: ['packages/cli', 'packages/backend', 'packages/app'], + include: ['packages/app'], + exclude: ['packages/back*'], + }), + ).resolves.toEqual(['packages/app']); }); }); diff --git a/packages/repo-tools/src/lib/paths.ts b/packages/repo-tools/src/lib/paths.ts index 73feeae21c..96fc92f035 100644 --- a/packages/repo-tools/src/lib/paths.ts +++ b/packages/repo-tools/src/lib/paths.ts @@ -15,90 +15,69 @@ */ import { findPaths } from '@backstage/cli-common'; -import { relative as relativePath, join } from 'path'; -import fs from 'fs-extra'; - -import g from 'glob'; -import isGlob from 'is-glob'; - -import { promisify } from 'util'; - -const glob = promisify(g); +import { PackageGraph } from '@backstage/cli-node'; +import { Minimatch } from 'minimatch'; +import { isAbsolute, relative as relativePath } from 'path'; /* eslint-disable-next-line no-restricted-syntax */ export const paths = findPaths(__dirname); -export async function resolvePackagePath( - packagePath: string, -): Promise { - const fullPackageDir = paths.resolveTargetRoot(packagePath); +/** @internal */ +export interface ResolvePackagesOptions { + paths?: string[]; + include?: string[]; + exclude?: string[]; +} - try { - const stat = await fs.stat(fullPackageDir); - if (!stat.isDirectory()) { - return undefined; - } +/** @internal */ +export async function resolvePackagePaths( + options: ResolvePackagesOptions = {}, +): Promise { + const { paths: providedPaths, include, exclude } = options; + let packages = await PackageGraph.listTargetPackages(); - const packageJsonPath = join(fullPackageDir, 'package.json'); - - await fs.access(packageJsonPath); - } catch (e) { - console.log(`folder omitted: ${fullPackageDir}, cause: ${e}`); - return undefined; + if (providedPaths && providedPaths.length > 0) { + packages = packages.filter(({ dir }) => + providedPaths.some( + path => + new Minimatch(path).match(relativePath(paths.targetRoot, dir)) || + isChildPath(dir, path), + ), + ); } - return relativePath(paths.targetRoot, fullPackageDir); -} -export async function findPackageDirs(selectedPaths: string[] = []) { - const packageDirs = new Array(); - for (const packageRoot of selectedPaths) { - // if the path contain any glob notation we resolve all the paths to process one by one - const dirs = isGlob(packageRoot) - ? await glob(packageRoot, { cwd: paths.targetRoot }) - : [packageRoot]; - for (const dir of dirs) { - const packageDir = await resolvePackagePath(dir); - if (!packageDir) { - continue; - } - packageDirs.push(packageDir); - } + if (include) { + packages = packages.filter(pkg => + include.some(pattern => + new Minimatch(pattern).match(relativePath(paths.targetRoot, pkg.dir)), + ), + ); } - return packageDirs; + + if (exclude) { + packages = packages.filter(pkg => + exclude.some( + pattern => + !new Minimatch(pattern).match( + relativePath(paths.targetRoot, pkg.dir), + ), + ), + ); + } + + return packages.map(pkg => relativePath(paths.targetRoot, pkg.dir)); } -/** - * Retrieves the list of package names in the "workspaces" field of the `package.json` file in the current workspace root. - * - * If the file does not exist, or the "workspaces" field is not present, returns `undefined`. - * - * @returns The list of package names, or `undefined` if not found. - */ -export async function getWorkspacePackagePathPatterns(): Promise< - string[] | undefined -> { - const pkgJson = await fs - .readJson(paths.resolveTargetRoot('package.json')) - .catch(error => { - if (error.code === 'ENOENT') { - return undefined; - } - throw error; - }); - const workspaces = pkgJson?.workspaces?.packages; - return workspaces; -} +/** @internal */ +export function isChildPath(base: string, path: string): boolean { + const relative = relativePath(base, path); + if (relative === '') { + // The same directory + return true; + } -/** - * Given a list of paths from the user, returns the listing package directories from the - * workspace. Returns all directories if no paths are given. - * @param cliPaths User given paths from CLI. - * @returns Matching package directories or all if no cli paths passed in. - */ -export async function getMatchingWorkspacePaths(cliPaths: string[]) { - const isAllPackages = !cliPaths?.length; - const selectedPaths = isAllPackages - ? await getWorkspacePackagePathPatterns() - : cliPaths; - return await findPackageDirs(selectedPaths); + const outsideBase = relative.startsWith('..'); // not outside base + const differentDrive = isAbsolute(relative); // on Windows, this means dir is on a different drive from base. + + return !outsideBase && !differentDrive; } diff --git a/yarn.lock b/yarn.lock index 7989ba7508..6d50dbdf18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9472,6 +9472,7 @@ __metadata: "@apidevtools/swagger-parser": ^10.1.0 "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" + "@backstage/cli-node": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": ^1.1.3