Merge pull request #17409 from backstage/rugvip/paths

repo-tools: include, exclude, and better path handling
This commit is contained in:
Patrik Oldsberg
2023-04-18 10:34:39 +02:00
committed by GitHub
12 changed files with 171 additions and 170 deletions
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/repo-tools': patch
---
Added `--include <patterns>` and `--exclude <patterns>` options for `api-reports` command that work based on package names.
+2
View File
@@ -28,6 +28,8 @@ Options:
--ci
--tsc
--docs
--include <pattern>
--exclude <pattern>
-a, --allow-warnings <allowWarningsPaths>
--allow-all-warnings
-o, --omit-messages <messageCodes>
+1
View File
@@ -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",
@@ -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<string>();
const cliPackageDirs = new Array<string>();
@@ -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(() => {
@@ -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('');
+10
View File
@@ -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 <pattern>',
'Only include packages matching the provided patterns',
(opt: string, opts: string[] = []) => [...opts, ...opt.split(',')],
)
.option(
'--exclude <pattern>',
'Exclude package matching the provided patterns',
(opt: string, opts: string[] = []) => [...opts, ...opt.split(',')],
)
.option(
'-a, --allow-warnings <allowWarningsPaths>',
'continue processing packages after getting errors on selected packages Allows glob patterns and comma separated values (i.e. packages/core,plugins/core-*)',
@@ -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<void>,
) {
const packages = await getMatchingWorkspacePaths(paths);
const packages = await resolvePackagePaths({ paths });
const limit = pLimit(5);
const resultsList = await Promise.all(
+62 -91
View File
@@ -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']);
});
});
+53 -74
View File
@@ -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<string | undefined> {
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<string[]> {
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<string>();
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;
}
+1
View File
@@ -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