Merge pull request #4911 from backstage/rugvip/todo

plugins: add todo and todo-backend
This commit is contained in:
Patrik Oldsberg
2021-03-17 09:53:47 +01:00
committed by GitHub
44 changed files with 2534 additions and 1 deletions
+2
View File
@@ -43,6 +43,7 @@ JavaScript
Kaewkasi
Knex
Kumar
Leasot
Lerna
Lindgren
Lundberg
@@ -91,6 +92,7 @@ Telenor
Templater
Templaters
Thauer
todo
Tolerations
Tuite
Trendyol
+9
View File
@@ -0,0 +1,9 @@
---
title: TODO
author: Spotify
authorUrl: https://github.com/spotify
category: Discovery
description: Browse TODO comments in your project's source code.
documentation: https://github.com/backstage/backstage/tree/master/plugins/todo
iconUrl: https://backstage.io/img/todo-logo.png
npmPackageName: '@backstage/plugin-todo'
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -34,6 +34,7 @@
"@backstage/plugin-sentry": "^0.3.8",
"@backstage/plugin-tech-radar": "^0.3.7",
"@backstage/plugin-techdocs": "^0.6.1",
"@backstage/plugin-todo": "^0.1.0",
"@backstage/plugin-user-settings": "^0.2.7",
"@backstage/theme": "^0.2.4",
"@material-ui/core": "^4.11.0",
@@ -81,6 +81,7 @@ import {
} from '@backstage/plugin-rollbar';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { EntityTodoContent } from '@backstage/plugin-todo';
import { Button, Grid } from '@material-ui/core';
import {
isPluginApplicableToEntity as isBuildkiteAvailable,
@@ -278,6 +279,11 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
title="Kafka"
element={<KafkaRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/todos"
title="TODOs"
element={<EntityTodoContent />}
/>
</EntityPageLayout>
);
@@ -323,6 +329,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
title="Code Insights"
element={<GitHubInsightsRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/todos"
title="TODOs"
element={<EntityTodoContent />}
/>
</EntityPageLayout>
);
@@ -338,6 +349,11 @@ const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
title="Docs"
element={<DocsRouter entity={entity} />}
/>
<EntityPageLayout.Content
path="/todos"
title="TODOs"
element={<EntityTodoContent />}
/>
</EntityPageLayout>
);
+1
View File
@@ -43,3 +43,4 @@ export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
export { plugin as Search } from '@backstage/plugin-search';
export { plugin as Org } from '@backstage/plugin-org';
export { plugin as Kafka } from '@backstage/plugin-kafka';
export { todoPlugin } from '@backstage/plugin-todo';
+1
View File
@@ -41,6 +41,7 @@
"@backstage/plugin-rollbar-backend": "^0.1.7",
"@backstage/plugin-scaffolder-backend": "^0.9.1",
"@backstage/plugin-techdocs-backend": "^0.6.4",
"@backstage/plugin-todo-backend": "^0.1.0",
"@gitbeaker/node": "^28.0.2",
"@octokit/rest": "^18.0.12",
"azure-devops-node-api": "^10.1.1",
+3
View File
@@ -43,6 +43,7 @@ import rollbar from './plugins/rollbar';
import scaffolder from './plugins/scaffolder';
import proxy from './plugins/proxy';
import techdocs from './plugins/techdocs';
import todo from './plugins/todo';
import graphql from './plugins/graphql';
import app from './plugins/app';
import { PluginEnvironment } from './types';
@@ -77,6 +78,7 @@ async function main() {
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar'));
const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
const todoEnv = useHotMemoize(module, () => createEnv('todo'));
const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes'));
const kafkaEnv = useHotMemoize(module, () => createEnv('kafka'));
const graphqlEnv = useHotMemoize(module, () => createEnv('graphql'));
@@ -88,6 +90,7 @@ async function main() {
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/todo', await todo(todoEnv));
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
apiRouter.use('/kafka', await kafka(kafkaEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogClient } from '@backstage/catalog-client';
import {
createRouter,
TodoReaderService,
TodoScmReader,
} from '@backstage/plugin-todo-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
reader,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
const todoReader = TodoScmReader.fromConfig(config, {
logger,
reader,
});
const catalogClient = new CatalogClient({ discoveryApi: discovery });
const todoService = new TodoReaderService({
todoReader,
catalogClient,
});
return await createRouter({ todoService });
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+63
View File
@@ -0,0 +1,63 @@
# @backstage/plugin-todo-backend
Backend for the `@backstage/plugin-todo` plugin. Assists in scanning for and listing `// TODO` comments in source code repositories.
## Installation
Install the `@backstage/plugin-todo-backend` package in your backend packages, and then integrate the plugin using the following default setup for `src/plugins/todo.ts`:
```ts
import { Router } from 'express';
import { CatalogClient } from '@backstage/catalog-client';
import {
createRouter,
TodoReaderService,
TodoScmReader,
} from '@backstage/plugin-todo-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
reader,
config,
discovery,
}: PluginEnvironment): Promise<Router> {
const todoReader = TodoScmReader.fromConfig(config, {
logger,
reader,
});
const catalogClient = new CatalogClient({ discoveryApi: discovery });
const todoService = new TodoReaderService({
todoReader,
catalogClient,
});
return await createRouter({ todoService });
}
```
## Scanned Files
The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location`
](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored.
## Parser Configuration
The `TodoScmReader` accepts a `TodoParser` option, which can be used to configure your own parser. The default one is based on [Leasot](https://github.com/pgilad/leasot) and supports a wide range of languages. You can add to the list of supported tags by configuring your own version of the built-in parser, for example:
```ts
import {
TodoScmReader,
createTodoParser,
} from '@backstage/plugin-todo-backend';
// ...
const todoReader = TodoScmReader.fromConfig(config, {
logger,
reader,
parser: createTodoParser({
additionalTags: ['NOTE', 'XXX'],
}),
});
```
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@backstage/plugin-todo-backend",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/todo-backend"
},
"scripts": {
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.5.5",
"@backstage/catalog-client": "^0.3.6",
"@backstage/catalog-model": "^0.7.3",
"@backstage/config": "^0.1.3",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.0",
"@types/express": "^4.17.6",
"cross-fetch": "^3.0.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"leasot": "^11.5.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.6.3",
"@types/supertest": "^2.0.8",
"msw": "^0.21.2",
"supertest": "^4.0.2"
},
"files": [
"dist"
]
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as index from './index';
describe('imports', () => {
it('should export known symbols only', () => {
expect(index).toEqual({
createRouter: expect.any(Function),
createTodoParser: expect.any(Function),
TodoScmReader: expect.any(Function),
TodoReaderService: expect.any(Function),
});
});
});
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './lib';
export * from './service';
@@ -0,0 +1,186 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
getVoidLogger,
ReadTreeResponse,
UrlReader,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { NotFoundError, NotModifiedError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import { createTodoParser } from './createTodoParser';
import { TodoScmReader } from './TodoScmReader';
function mockReader(): jest.Mocked<UrlReader> {
return {
read: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
} as jest.Mocked<UrlReader>;
}
describe('TodoScmReader', () => {
it('should be created from config', () => {
const todoReader = TodoScmReader.fromConfig(new ConfigReader({}), {
logger: getVoidLogger(),
reader: mockReader(),
});
expect(todoReader).toEqual(expect.any(TodoScmReader));
});
it('should read TODOs', async () => {
const reader = mockReader();
const todoReader = new TodoScmReader({
logger: getVoidLogger(),
reader,
integrations: ScmIntegrations.fromConfig(new ConfigReader({})),
});
reader.readTree.mockResolvedValueOnce({
etag: 'my-etag',
files: async () => [
{
content: async () => Buffer.from('// TODO: my-todo', 'utf8'),
path: 'my-folder/my-file.js',
},
],
} as ReadTreeResponse);
const url = 'https://github.com/backstage/backstage/catalog-info.yaml';
const expected = {
items: [
{
text: 'my-todo',
tag: 'TODO',
lineNumber: 1,
repoFilePath: 'my-folder/my-file.js',
viewUrl:
'https://github.com/backstage/backstage/my-folder/my-file.js#L1',
},
],
};
await expect(todoReader.readTodos({ url })).resolves.toEqual(expected);
expect(reader.readTree).toHaveBeenCalledTimes(1);
expect(reader.readTree).toHaveBeenCalledWith(
'https://github.com/backstage/backstage/catalog-info.yaml',
{
etag: undefined,
filter: expect.any(Function),
},
);
// Filter function should filter out dotfiles
const filterFunc = reader.readTree.mock.calls[0][1]!.filter!;
expect(filterFunc('my-file.js')).toBe(true);
expect(filterFunc('my-folder/my-file.js')).toBe(true);
expect(filterFunc('.my-file.js')).toBe(false);
expect(filterFunc('.my-folder/my-file.js')).toBe(false);
expect(filterFunc('my-folder/.my-file.js')).toBe(false);
// A NotModifiedError should return a cached result
reader.readTree.mockRejectedValueOnce(new NotModifiedError('nope'));
await expect(todoReader.readTodos({ url })).resolves.toEqual(expected);
expect(reader.readTree).toHaveBeenCalledTimes(2);
expect(reader.readTree).toHaveBeenLastCalledWith(
'https://github.com/backstage/backstage/catalog-info.yaml',
{
etag: 'my-etag',
filter: expect.any(Function),
},
);
// Other errors should re-throw
reader.readTree.mockRejectedValueOnce(new NotFoundError('not found'));
await expect(todoReader.readTodos({ url })).rejects.toThrow('not found');
});
it('should use custom parser', async () => {
const parser = jest.fn(createTodoParser({ additionalTags: ['XXX'] }));
const reader = mockReader();
const todoReader = new TodoScmReader({
logger: getVoidLogger(),
reader,
parser,
integrations: ScmIntegrations.fromConfig(new ConfigReader({})),
});
reader.readTree.mockResolvedValueOnce({
files: async () => [
{
content: async () => Buffer.from('-- XXX: my-todo', 'utf8'),
path: 'my-file.lua',
},
],
} as ReadTreeResponse);
await expect(
todoReader.readTodos({
url: 'https://github.com/backstage/backstage/catalog-info.yaml',
}),
).resolves.toEqual({
items: [
{
text: 'my-todo',
tag: 'XXX',
lineNumber: 1,
repoFilePath: 'my-file.lua',
viewUrl: 'https://github.com/backstage/backstage/my-file.lua#L1',
},
],
});
expect(parser).toHaveBeenCalledTimes(1);
expect(parser).toHaveBeenCalledWith({
content: '-- XXX: my-todo',
path: 'my-file.lua',
});
});
it('should log and ignore parser errors', async () => {
const logger = getVoidLogger();
const errorSpy = jest.spyOn(logger, 'error');
const reader = mockReader();
const todoReader = new TodoScmReader({
logger,
reader,
parser() {
throw new Error('failed to parse');
},
integrations: ScmIntegrations.fromConfig(new ConfigReader({})),
});
reader.readTree.mockResolvedValueOnce({
files: async () => [
{
content: async () => Buffer.from('# XXX: my-todo', 'utf8'),
path: 'my-file.sh',
},
],
} as ReadTreeResponse);
await expect(
todoReader.readTodos({
url: 'https://github.com/o/r/catalog-info.yaml',
}),
).resolves.toEqual({ items: [] });
expect(errorSpy).toHaveBeenCalledWith(
'Failed to parse TODO in https://github.com/o/r/catalog-info.yaml at my-file.sh, Error: failed to parse',
);
});
});
@@ -0,0 +1,125 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UrlReader } from '@backstage/backend-common';
import { ScmIntegrations } from '@backstage/integration';
import { Logger } from 'winston';
import {
ReadTodosOptions,
ReadTodosResult,
TodoItem,
TodoParser,
TodoReader,
} from './types';
import { Config } from '@backstage/config';
import { createTodoParser } from './createTodoParser';
type Options = {
logger: Logger;
reader: UrlReader;
integrations: ScmIntegrations;
parser?: TodoParser;
};
type CacheItem = {
etag: string;
result: ReadTodosResult;
};
export class TodoScmReader implements TodoReader {
private readonly logger: Logger;
private readonly reader: UrlReader;
private readonly parser: TodoParser;
private readonly integrations: ScmIntegrations;
private readonly cache = new Map<string, CacheItem>();
static fromConfig(config: Config, options: Omit<Options, 'integrations'>) {
return new TodoScmReader({
...options,
integrations: ScmIntegrations.fromConfig(config),
});
}
constructor(options: Options) {
this.logger = options.logger;
this.reader = options.reader;
this.parser = options.parser ?? createTodoParser();
this.integrations = options.integrations;
}
async readTodos({ url }: ReadTodosOptions): Promise<ReadTodosResult> {
const cacheItem = this.cache.get(url);
try {
const newCacheItem = await this.doReadTodos({ url }, cacheItem?.etag);
this.cache.set(url, newCacheItem);
return newCacheItem.result;
} catch (error) {
if (cacheItem && error.name === 'NotModifiedError') {
return cacheItem.result;
}
throw error;
}
}
private async doReadTodos(
{ url }: ReadTodosOptions,
etag?: string,
): Promise<CacheItem> {
const tree = await this.reader.readTree(url, {
etag,
filter(path) {
return !path.startsWith('.') && !path.includes('/.');
},
});
const files = await tree.files();
this.logger.info(`Read ${files.length} files from ${url}`);
const todos = new Array<TodoItem>();
for (const file of files) {
const content = await file.content();
try {
const items = this.parser({
path: file.path,
content: content.toString('utf8'),
});
todos.push(
...items.map(({ lineNumber, text, tag, author }) => ({
text,
tag,
author,
lineNumber,
repoFilePath: file.path,
viewUrl: this.integrations.resolveUrl({
url: file.path,
base: url,
lineNumber,
}),
})),
);
} catch (error) {
this.logger.error(
`Failed to parse TODO in ${url} at ${file.path}, ${error}`,
);
}
}
return { result: { items: todos }, etag: tree.etag };
}
}
@@ -0,0 +1,127 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createTodoParser } from './createTodoParser';
const comment = '//'; // just so we don't have all of these should up as TODOs :D
describe('createTodoParser', () => {
it('should create a parser that parses TODOs', () => {
const parser = createTodoParser();
const output = parser({
path: 'my-file.js',
content: `
${comment} TODO(user1): todo 1
${comment}TODO(user2): todo 2
${comment}todo: todo 3
${comment}@todo: todo 4
${comment}@fixme(user5): todo 5
${comment} @TODO: todo 6 /user6
${comment} FIXME: todo 7 /user7
`,
});
expect(output).toEqual([
{
text: 'todo 1',
author: 'user1',
lineNumber: 2,
tag: 'TODO',
},
{
text: 'todo 2',
author: 'user2',
lineNumber: 3,
tag: 'TODO',
},
{
text: 'todo 3',
lineNumber: 4,
tag: 'TODO',
},
{
text: 'todo 4',
lineNumber: 5,
tag: 'TODO',
},
{
text: 'todo 5',
author: 'user5',
lineNumber: 7,
tag: 'FIXME',
},
{
text: 'todo 6',
author: 'user6',
lineNumber: 8,
tag: 'TODO',
},
{
text: 'todo 7',
author: 'user7',
lineNumber: 9,
tag: 'FIXME',
},
]);
});
it('should support custom tags', () => {
const content = `
${comment} TODO: todo 1
${comment} MYTAG: todo 2
${comment} MYTAG(user): todo 3
`;
expect(
createTodoParser()({
path: 'my-file.js',
content,
}),
).toEqual([{ text: 'todo 1', tag: 'TODO', lineNumber: 2 }]);
expect(
createTodoParser({ additionalTags: ['MYTAG'] })({
path: 'my-file.js',
content,
}),
).toEqual([
{ text: 'todo 1', tag: 'TODO', lineNumber: 2 },
{ text: 'todo 2', tag: 'MYTAG', lineNumber: 3 },
{ text: 'todo 3', author: 'user', tag: 'MYTAG', lineNumber: 4 },
]);
});
it('should support multiple languages', () => {
const parser = createTodoParser();
const content = `
-- TODO: todo 1
# TODO: todo 2
${comment} TODO: todo 3
${comment[0]}* TODO: todo 4 */
`;
const todo1 = { text: 'todo 1', tag: 'TODO', lineNumber: 2 };
const todo2 = { text: 'todo 2', tag: 'TODO', lineNumber: 3 };
const todo3 = { text: 'todo 3', tag: 'TODO', lineNumber: 4 };
const todo4 = { text: 'todo 4', tag: 'TODO', lineNumber: 5 };
expect(parser({ path: 'f.py', content })).toEqual([todo2]);
expect(parser({ path: 'f.lua', content })).toEqual([todo1]);
expect(parser({ path: 'f.java', content })).toEqual([todo3, todo4]);
expect(parser({ path: 'f.sh', content })).toEqual([todo2]);
expect(parser({ path: 'f.sql', content })).toEqual([todo1, todo3, todo4]);
expect(parser({ path: 'f.json', content })).toEqual([]);
});
});
@@ -0,0 +1,44 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { extname } from 'path';
import { parse } from 'leasot';
import { TodoParser } from './types';
export type TodoParserOptions = {
/** Custom tags to support in addition to TODO and FIXME */
additionalTags?: string[];
};
export function createTodoParser(options: TodoParserOptions = {}): TodoParser {
return ({ content, path }) => {
try {
const comments = parse(content, {
customTags: options.additionalTags,
extension: extname(path),
});
return comments.map(comment => ({
text: comment.text,
tag: comment.tag,
author: comment.ref || undefined,
lineNumber: comment.line,
}));
} catch /* ignore unsupported extensions */ {
return [];
}
};
}
@@ -0,0 +1,24 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TodoScmReader } from './TodoScmReader';
export { createTodoParser } from './createTodoParser';
export type {
TodoItem,
TodoReader,
ReadTodosOptions,
ReadTodosResult,
} from './types';
@@ -0,0 +1,70 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type TodoItem = {
/** The contents of the TODO comment */
text: string;
/** The tag used, e.g. TODO, FIXME */
tag: string;
/** References author, if any */
author?: string;
/** URL used to view the file */
viewUrl?: string;
/** The line number of the file that the TODO occurs at */
lineNumber?: number;
/** The path of the file containing the TODO within the repo */
repoFilePath?: string;
};
export type ReadTodosOptions = {
/**
* Base URLs defining the root at which to search for TODOs
*/
url: string;
};
export type ReadTodosResult = {
/**
* TODO items found at the given locations
*/
items: TodoItem[];
};
export interface TodoReader {
/**
* Searches for TODO items in code at a given location
*/
readTodos(options: ReadTodosOptions): Promise<ReadTodosResult>;
}
type TodoParserContext = {
content: string;
path: string;
};
type TodoParserResult = {
text: string;
tag: string;
author?: string;
lineNumber: number;
};
export type TodoParser = (ctx: TodoParserContext) => TodoParserResult[];
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './TodoReader';
@@ -0,0 +1,362 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { TodoItem, TodoReader } from '../lib';
import { TodoReaderService } from './TodoReaderService';
const entityName = {
kind: 'Component',
namespace: 'default',
name: 'my-component',
};
const mockEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
},
spec: {
type: 'library',
},
};
function mockCatalogClient(entity?: Entity): jest.Mocked<CatalogApi> {
const mock = {
addLocation: jest.fn(),
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
};
if (entity) {
mock.getEntityByName.mockReturnValue(entity);
}
return mock;
}
function mockTodoReader(items?: TodoItem[]): jest.Mocked<TodoReader> {
const mock = {
readTodos: jest.fn(),
};
if (items) {
mock.readTodos.mockReturnValue({ items });
}
return mock;
}
function makeTodo(text: string, extra?: Partial<TodoItem>): TodoItem {
return { text, tag: 'TODO', ...extra };
}
describe('TodoReaderService', () => {
it('should be created', () => {
const service = new TodoReaderService({
todoReader: mockTodoReader(),
catalogClient: mockCatalogClient(),
});
expect(service.listTodos).toEqual(expect.any(Function));
});
it('should list 0 todos', async () => {
const todoReader = mockTodoReader([]);
const catalogClient = mockCatalogClient(mockEntity);
const service = new TodoReaderService({ todoReader, catalogClient });
await expect(service.listTodos({ entity: entityName })).resolves.toEqual({
items: [],
totalCount: 0,
offset: 0,
limit: 10,
});
expect(catalogClient.getEntityByName).toHaveBeenCalledWith(entityName);
});
it('should list, order, and filter todos', async () => {
const todo1 = makeTodo('todo1');
const todo2 = makeTodo('todo2', { author: 'foo' });
const todo3 = makeTodo('todo3', {
author: 'bar',
repoFilePath: 'a/b/c.js',
});
const todo4 = makeTodo('todo4', { author: 'baz', viewUrl: 'example.com' });
const todo5 = makeTodo('todo5', { tag: 'FIXME' });
const todoReader = mockTodoReader([todo1, todo2, todo3, todo4, todo5]);
const catalogClient = mockCatalogClient({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {
['backstage.io/source-location']:
'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
},
});
const service = new TodoReaderService({ todoReader, catalogClient });
await expect(service.listTodos({ entity: entityName })).resolves.toEqual({
items: [todo1, todo2, todo3, todo4, todo5],
totalCount: 5,
offset: 0,
limit: 10,
});
await expect(
service.listTodos({ entity: entityName, limit: 3 }),
).resolves.toEqual({
items: [todo1, todo2, todo3],
totalCount: 5,
offset: 0,
limit: 3,
});
await expect(
service.listTodos({ entity: entityName, offset: 2 }),
).resolves.toEqual({
items: [todo3, todo4, todo5],
totalCount: 5,
offset: 2,
limit: 10,
});
await expect(
service.listTodos({
entity: entityName,
orderBy: { field: 'author', direction: 'asc' },
}),
).resolves.toEqual({
items: [todo3, todo4, todo2, todo1, todo5],
totalCount: 5,
offset: 0,
limit: 10,
});
await expect(
service.listTodos({
entity: entityName,
orderBy: { field: 'author', direction: 'desc' },
offset: -2,
}),
).resolves.toEqual({
items: [todo2, todo4, todo3, todo1, todo5],
totalCount: 5,
offset: 0,
limit: 10,
});
await expect(
service.listTodos({
entity: entityName,
orderBy: { field: 'text', direction: 'desc' },
offset: 2,
limit: 2,
}),
).resolves.toEqual({
items: [todo3, todo2],
totalCount: 5,
offset: 2,
limit: 2,
});
await expect(
service.listTodos({
entity: entityName,
filters: [{ field: 'text', value: '*4' }],
}),
).resolves.toEqual({ items: [todo4], totalCount: 1, offset: 0, limit: 10 });
await expect(
service.listTodos({
entity: entityName,
filters: [{ field: 'author', value: 'ba*' }],
}),
).resolves.toEqual({
items: [todo3, todo4],
totalCount: 2,
offset: 0,
limit: 10,
});
await expect(
service.listTodos({
entity: entityName,
filters: [{ field: 'author', value: 'ba*' }],
limit: 1,
}),
).resolves.toEqual({ items: [todo3], totalCount: 2, offset: 0, limit: 1 });
await expect(
service.listTodos({
entity: entityName,
filters: [
{ field: 'author', value: 'ba*' },
{ field: 'tag', value: 'FIXME' },
],
}),
).resolves.toEqual({ items: [], totalCount: 0, offset: 0, limit: 10 });
await expect(
service.listTodos({
entity: entityName,
filters: [{ field: 'tag', value: 'FIXME' }],
limit: 500,
}),
).resolves.toEqual({ items: [todo5], totalCount: 1, offset: 0, limit: 50 });
await expect(
service.listTodos({ entity: entityName, limit: -5 }),
).resolves.toEqual({ items: [], totalCount: 5, offset: 0, limit: 0 });
});
it('should have configurable pagination', async () => {
const todo1 = makeTodo('todo1');
const todo2 = makeTodo('todo2');
const todo3 = makeTodo('todo3');
const todo4 = makeTodo('todo4');
const todo5 = makeTodo('todo5');
const todoReader = mockTodoReader([todo1, todo2, todo3, todo4, todo5]);
const catalogClient = mockCatalogClient(mockEntity);
const service = new TodoReaderService({
todoReader,
catalogClient,
defaultPageSize: 2,
maxPageSize: 3,
});
await expect(service.listTodos({ entity: entityName })).resolves.toEqual({
items: [todo1, todo2],
totalCount: 5,
offset: 0,
limit: 2,
});
await expect(
service.listTodos({ entity: entityName, limit: 1 }),
).resolves.toEqual({
items: [todo1],
totalCount: 5,
offset: 0,
limit: 1,
});
await expect(
service.listTodos({ entity: entityName, limit: 3 }),
).resolves.toEqual({
items: [todo1, todo2, todo3],
totalCount: 5,
offset: 0,
limit: 3,
});
await expect(
service.listTodos({ entity: entityName, limit: 4 }),
).resolves.toEqual({
items: [todo1, todo2, todo3],
totalCount: 5,
offset: 0,
limit: 3,
});
});
it('should require an entity', async () => {
const service = new TodoReaderService({
todoReader: mockTodoReader(),
catalogClient: mockCatalogClient(),
});
await expect(service.listTodos({})).rejects.toThrow(
'Entity filter is required to list TODOs',
);
});
it('should throw if entity is not found', async () => {
const todoReader = mockTodoReader([]);
const catalogClient = mockCatalogClient();
const service = new TodoReaderService({ todoReader, catalogClient });
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
expect.objectContaining({
name: 'NotFoundError',
message: 'Entity not found, Component:default/my-component',
}),
);
expect(catalogClient.getEntityByName).toHaveBeenCalledWith(entityName);
});
it('should throw if entity does not have a location', async () => {
const todoReader = mockTodoReader([]);
const catalogClient = mockCatalogClient({
...mockEntity,
metadata: { ...mockEntity.metadata, annotations: undefined },
});
const service = new TodoReaderService({ todoReader, catalogClient });
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
expect.objectContaining({
name: 'InputError',
message:
'No entity location annotation found for Component:my-component',
}),
);
});
it('should throw if entity has an invalid location', async () => {
const todoReader = mockTodoReader([]);
const catalogClient = mockCatalogClient({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {
['backstage.io/managed-by-location']: 'file:../info.yaml',
},
},
});
const service = new TodoReaderService({ todoReader, catalogClient });
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
expect.objectContaining({
name: 'InputError',
message: `Invalid entity location type for Component:my-component, got 'file'`,
}),
);
});
it('should throw if entity has an invalid source location', async () => {
const todoReader = mockTodoReader([]);
const catalogClient = mockCatalogClient({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {
['backstage.io/source-location']: 'file:../info.yaml',
},
},
});
const service = new TodoReaderService({ todoReader, catalogClient });
await expect(service.listTodos({ entity: entityName })).rejects.toEqual(
expect.objectContaining({
name: 'InputError',
message: `Invalid entity source location type for Component:my-component, got 'file'`,
}),
);
});
});
@@ -0,0 +1,152 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import {
LOCATION_ANNOTATION,
SOURCE_LOCATION_ANNOTATION,
serializeEntityRef,
Entity,
parseLocationReference,
} from '@backstage/catalog-model';
import { TodoReader } from '../lib';
import { ListTodosRequest, ListTodosResponse, TodoService } from './types';
const DEFAULT_DEFAULT_PAGE_SIZE = 10;
const DEFAULT_MAX_PAGE_SIZE = 50;
type Options = {
todoReader: TodoReader;
catalogClient: CatalogApi;
maxPageSize?: number;
defaultPageSize?: number;
};
function wildcardRegex(str: string): RegExp {
const pattern = str
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
return new RegExp(`^${pattern}$`, 'i');
}
export class TodoReaderService implements TodoService {
private readonly todoReader: TodoReader;
private readonly catalogClient: CatalogApi;
private readonly maxPageSize: number;
private readonly defaultPageSize: number;
constructor(options: Options) {
this.todoReader = options.todoReader;
this.catalogClient = options.catalogClient;
this.maxPageSize = options.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE;
this.defaultPageSize = options.defaultPageSize ?? DEFAULT_DEFAULT_PAGE_SIZE;
}
async listTodos(req: ListTodosRequest): Promise<ListTodosResponse> {
if (!req.entity) {
throw new InputError('Entity filter is required to list TODOs');
}
const entity = await this.catalogClient.getEntityByName(req.entity);
if (!entity) {
throw new NotFoundError(
`Entity not found, ${serializeEntityRef(req.entity)}`,
);
}
const url = this.getEntitySourceUrl(entity);
const todos = await this.todoReader.readTodos({ url });
let limit = req.limit ?? this.defaultPageSize;
if (limit < 0) {
limit = 0;
} else if (limit > this.maxPageSize) {
limit = this.maxPageSize;
}
let offset = req.offset ?? 0;
if (offset < 0) {
offset = 0;
}
let items = todos.items;
const { orderBy, filters } = req;
if (filters) {
for (const { field, value } of filters) {
const pattern = wildcardRegex(value);
items = items.filter(item => item[field]?.match(pattern));
}
}
if (orderBy) {
const dir = orderBy.direction === 'asc' ? 1 : -1;
const field = orderBy.field;
items = items.slice().sort((item1, item2) => {
const field1 = item1[field];
const field2 = item2[field];
if (field1 && field2) {
return dir * field1.localeCompare(field2, 'en-US');
} else if (field1 && !field2) {
return -1;
} else if (!field1 && field2) {
return 1;
}
return 0;
});
}
return {
items: items.slice(offset, offset + limit),
totalCount: items.length,
offset,
limit,
};
}
private getEntitySourceUrl(entity: Entity) {
const sourceLocation =
entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];
if (sourceLocation) {
const parsed = parseLocationReference(sourceLocation);
if (parsed.type !== 'url') {
throw new InputError(
`Invalid entity source location type for ${serializeEntityRef(
entity,
)}, got '${parsed.type}'`,
);
}
return parsed.target;
}
const location = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (location) {
const parsed = parseLocationReference(location);
if (parsed.type !== 'url') {
throw new InputError(
`Invalid entity location type for ${serializeEntityRef(
entity,
)}, got '${parsed.type}'`,
);
}
return parsed.target;
}
throw new InputError(
`No entity location annotation found for ${serializeEntityRef(entity)}`,
);
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createRouter } from './router';
export type { TodoService, ListTodosRequest, ListTodosResponse } from './types';
export { TodoReaderService } from './TodoReaderService';
@@ -0,0 +1,220 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import request from 'supertest';
import { errorHandler } from '@backstage/backend-common';
import {
createRouter,
parseFilterParam,
parseIntegerParam,
parseOrderByParam,
} from './router';
import { TodoService } from './types';
const mockListBody = {
items: [{ text: 'my todo', tag: 'TODO' }],
totalCount: 1,
offset: 0,
limit: 10,
};
function matchErrorResponse(status: number, name: string, message: string) {
return {
status,
body: expect.objectContaining({
error: { name, message },
response: {
statusCode: status,
},
}),
};
}
describe('createRouter', () => {
let app: express.Express;
const mockService: jest.Mocked<TodoService> = {
listTodos: jest.fn(),
};
beforeAll(async () => {
const router = await createRouter({
todoService: mockService,
});
app = express().use(router).use(errorHandler());
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /todos', () => {
it('returns list without query', async () => {
mockService.listTodos.mockResolvedValueOnce(mockListBody);
const response = await request(app).get('/v1/todos');
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockListBody);
expect(mockService.listTodos).toHaveBeenCalledWith({
entity: undefined,
offset: undefined,
limit: undefined,
});
});
it('forwards pagination query', async () => {
mockService.listTodos.mockResolvedValueOnce(mockListBody);
const response = await request(app).get('/v1/todos?offset=5&limit=3');
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockListBody);
expect(mockService.listTodos).toHaveBeenCalledWith({
entity: undefined,
offset: 5,
limit: 3,
});
});
it('forwards entity query', async () => {
mockService.listTodos.mockResolvedValueOnce(mockListBody);
const response = await request(app).get(
'/v1/todos?entity=component:my-component',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockListBody);
expect(mockService.listTodos).toHaveBeenCalledWith({
entity: {
name: 'my-component',
kind: 'component',
namespace: 'default',
},
offset: undefined,
limit: undefined,
});
});
it('rejects invalid queries', async () => {
await expect(
request(app).get('/v1/todos?entity=k:n&entity=k:n'),
).resolves.toMatchObject(
matchErrorResponse(400, 'InputError', 'entity query must be a string'),
);
await expect(
request(app).get('/v1/todos?entity=:n'),
).resolves.toMatchObject(
matchErrorResponse(
400,
'InputError',
'Invalid entity ref, TypeError: Entity reference ":n" was not on the form [<kind>:][<namespace>/]<name>',
),
);
await expect(
request(app).get('/v1/todos?offset=1.5'),
).resolves.toMatchObject(
matchErrorResponse(
400,
'InputError',
'invalid offset query, not an integer',
),
);
expect(mockService.listTodos).not.toHaveBeenCalled();
});
});
});
describe('parseIntegerParam', () => {
it('should parse a param', () => {
expect(parseIntegerParam('1', 'ctx')).toBe(1);
});
it('should reject invalid params', () => {
expect(() => parseIntegerParam(['1'], 'ctx')).toThrow(
'invalid ctx, must be a string',
);
expect(() => parseIntegerParam('1.5', 'ctx')).toThrow(
'invalid ctx, not an integer',
);
expect(() => parseIntegerParam('foo', 'ctx')).toThrow(
'invalid ctx, not an integer',
);
expect(() => parseIntegerParam('1foo', 'ctx')).toThrow(
'invalid ctx, not an integer',
);
});
});
describe('parseOrderByParam', () => {
it('should parse a param', () => {
expect(parseOrderByParam('a=asc', ['a'])).toEqual({
field: 'a',
direction: 'asc',
});
expect(parseOrderByParam('a=desc', ['a'])).toEqual({
field: 'a',
direction: 'desc',
});
});
it('should reject invalid params', () => {
expect(() => parseOrderByParam(['a=asc'], ['a'])).toThrow(
'invalid orderBy query, must be a string',
);
expect(() => parseOrderByParam('a=desc', ['b', 'c'])).toThrow(
'invalid orderBy field, must be one of b, c',
);
expect(() => parseOrderByParam('b=down', ['a'])).toThrow(
`invalid orderBy query, order direction must be 'asc' or 'desc'`,
);
expect(() => parseOrderByParam('=asc', ['a'])).toThrow(
'invalid orderBy query, field name is empty',
);
});
});
describe('parseFilterParam', () => {
it('should parse a param', () => {
expect(parseFilterParam('a=b', ['a'])).toEqual([
{ field: 'a', value: 'b' },
]);
expect(parseFilterParam(['a=b=c', 'c=d*'], ['a', 'c'])).toEqual([
{ field: 'a', value: 'b=c' },
{ field: 'c', value: 'd*' },
]);
});
it('should reject invalid params', () => {
expect(() => parseFilterParam({ a: 'a=b' }, ['a'])).toThrow(
'invalid filter query, must be a string or list of strings',
);
expect(() => parseFilterParam('a=b', ['b', 'c'])).toThrow(
'invalid filter field, must be one of b, c',
);
expect(() => parseFilterParam('b', ['a'])).toThrow(
`invalid filter query, must separate field from value using '='`,
);
expect(() => parseFilterParam('=a', ['a'])).toThrow(
`invalid filter query, must separate field from value using '='`,
);
expect(() => parseFilterParam('a=', ['a'])).toThrow(
'invalid filter query, value may not be empty',
);
});
});
+159
View File
@@ -0,0 +1,159 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityName, parseEntityName } from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { TodoService } from './types';
const TODO_FIELDS = [
'text',
'tag',
'author',
'viewUrl',
'repoFilePath',
] as const;
export interface RouterOptions {
todoService: TodoService;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { todoService } = options;
const router = Router();
router.use(express.json());
router.get('/v1/todos', async (req, res) => {
const offset = parseIntegerParam(req.query.offset, 'offset query');
const limit = parseIntegerParam(req.query.limit, 'limit query');
const orderBy = parseOrderByParam(req.query.orderBy, TODO_FIELDS);
const filters = parseFilterParam(req.query.filter, TODO_FIELDS);
const entityRef = req.query.entity;
if (entityRef && typeof entityRef !== 'string') {
throw new InputError(`entity query must be a string`);
}
let entity: EntityName | undefined = undefined;
if (entityRef) {
try {
entity = parseEntityName(entityRef);
} catch (error) {
throw new InputError(`Invalid entity ref, ${error}`);
}
}
const todos = await todoService.listTodos({
entity,
offset,
limit,
orderBy,
filters,
});
res.json(todos);
});
return router;
}
export function parseIntegerParam(
str: unknown,
ctx: string,
): number | undefined {
if (str === undefined) {
return undefined;
}
if (typeof str !== 'string') {
throw new InputError(`invalid ${ctx}, must be a string`);
}
const parsed = parseInt(str, 10);
if (!Number.isInteger(parsed) || String(parsed) !== str) {
throw new InputError(`invalid ${ctx}, not an integer`);
}
return parsed;
}
export function parseOrderByParam<T extends readonly string[]>(
str: unknown,
allowedFields: T,
): { field: T[number]; direction: 'asc' | 'desc' } | undefined {
if (str === undefined) {
return undefined;
}
if (typeof str !== 'string') {
throw new InputError(`invalid orderBy query, must be a string`);
}
const [field, direction] = str.split('=');
if (!field) {
throw new InputError(`invalid orderBy query, field name is empty`);
}
if (direction !== 'asc' && direction !== 'desc') {
throw new InputError(
`invalid orderBy query, order direction must be 'asc' or 'desc'`,
);
}
if (field && !allowedFields.includes(field)) {
throw new InputError(
`invalid orderBy field, must be one of ${allowedFields.join(', ')}`,
);
}
return { field, direction };
}
export function parseFilterParam<T extends readonly string[]>(
str: unknown,
allowedFields: T,
): { field: T[number]; value: string }[] | undefined {
if (str === undefined) {
return undefined;
}
const filters = new Array<{ field: T[number]; value: string }>();
const strs = [str].flat();
for (const filterStr of strs) {
if (typeof filterStr !== 'string') {
throw new InputError(
`invalid filter query, must be a string or list of strings`,
);
}
const splitIndex = filterStr.indexOf('=');
if (splitIndex <= 0) {
throw new InputError(
`invalid filter query, must separate field from value using '='`,
);
}
const field = filterStr.slice(0, splitIndex);
if (!allowedFields.includes(field)) {
throw new InputError(
`invalid filter field, must be one of ${allowedFields.join(', ')}`,
);
}
const value = filterStr.slice(splitIndex + 1);
if (!value) {
throw new InputError(`invalid filter query, value may not be empty`);
}
filters.push({ field, value });
}
return filters;
}
+46
View File
@@ -0,0 +1,46 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import { TodoItem } from '../lib';
type Fields = 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath';
export type ListTodosRequest = {
entity?: EntityName;
offset?: number;
limit?: number;
orderBy?: {
field: Fields;
direction: 'asc' | 'desc';
};
filters?: {
field: Fields;
/** Value to filter by, with '*' used as wildcard */
value: string;
}[];
};
export type ListTodosResponse = {
items: TodoItem[];
totalCount: number;
offset: number;
limit: number;
};
export interface TodoService {
listTodos(req: ListTodosRequest): Promise<ListTodosResponse>;
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+35
View File
@@ -0,0 +1,35 @@
# @backstage/plugin-todo
This plugin lists `// TODO` comments in source code. It currently exports a single component extension for use on entity pages.
## Format
The default parser uses [Leasot](https://github.com/pgilad/leasot), which supports a wide range of languages. By default it supports the `TODO` and `FIXME` tags, along with `@` prefix and author reference through with either a `(<name>)` suffix or trailing `/<name>`. For more information on how to configure the parser, see `@backstage/plugin-todo-backend`.
Below are some examples of formats that are supported by default:
```ts
// TODO: Ideally this would be working
// TODO(Rugvip): Not sure why this works, investigate
// @todo: This worked last Monday /Rugvip
// FIXME Nobody knows why this is here
```
Note the trailing comments are not supported, the following TODO would not be listed:
```ts
function reverse(str: string) {
return str.reverse(); // TODO: optimize
}
```
The scanner also ignores all dot-files and directories, meaning TODOs inside of those will not be listed.
## Extensions
| name | description |
| ------------------- | ------------------------------------------------------------------------------- |
| `EntityTodoContent` | Content for an entity page, showing a table of TODO items for the given entity. |
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
Content,
Header,
HeaderLabel,
Page,
} from '@backstage/core';
import { createDevApp } from '@backstage/dev-utils';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import OnlineIcon from '@material-ui/icons/Cloud';
import OfflineIcon from '@material-ui/icons/Storage';
import React from 'react';
import { EntityTodoContent, todoApiRef, todoPlugin } from '../src';
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage',
annotations: {
[LOCATION_ANNOTATION]:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
},
},
spec: {
type: 'library',
},
};
const mockedApi = {
listTodos: async () => ({
items: [
{
text: 'Make sure this works',
tag: 'TODO',
author: 'Rugvip',
viewUrl: 'https://github.com/backstage/backstage',
},
],
totalCount: 15,
offset: 0,
limit: 10,
}),
};
createDevApp()
.registerPlugin(todoPlugin)
.addPage({
element: (
<ApiProvider apis={ApiRegistry.with(todoApiRef, mockedApi)}>
<EntityProvider entity={entity}>
<Page themeId="service">
<Header title="Mocked TODO Data">
<HeaderLabel label="Mode" value="Development" />
</Header>
<Content>
<EntityTodoContent />
</Content>
</Page>
</EntityProvider>
</ApiProvider>
),
title: 'Entity Todo Content',
icon: OfflineIcon,
})
.addPage({
element: (
<EntityProvider entity={entity}>
<Page themeId="service">
<Header title="Live TODO Data">
<HeaderLabel label="Mode" value="Development" />
</Header>
<Content>
<EntityTodoContent />
</Content>
</Page>
</EntityProvider>
),
title: 'Backend Connected',
icon: OnlineIcon,
})
.render();
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@backstage/plugin-todo",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/todo"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.3",
"@backstage/core": "^0.7.1",
"@backstage/errors": "^0.1.1",
"@backstage/plugin-catalog-react": "^0.1.1",
"@backstage/theme": "^0.2.4",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.6.4",
"@backstage/dev-utils": "^0.1.13",
"@backstage/test-utils": "^0.1.8",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"msw": "^0.21.2",
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
]
}
+80
View File
@@ -0,0 +1,80 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { serializeEntityRef } from '@backstage/catalog-model';
import { ResponseError } from '@backstage/errors';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { TodoApi, TodoListOptions, TodoListResult } from './types';
interface Options {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}
export class TodoClient implements TodoApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
async listTodos({
entity,
offset,
limit,
orderBy,
filters,
}: TodoListOptions): Promise<TodoListResult> {
const baseUrl = await this.discoveryApi.getBaseUrl('todo');
const token = await this.identityApi.getIdToken();
const query = new URLSearchParams();
if (entity) {
query.set('entity', serializeEntityRef(entity) as string);
}
if (typeof offset === 'number') {
query.set('offset', String(offset));
}
if (typeof limit === 'number') {
query.set('limit', String(limit));
}
if (orderBy) {
query.set('orderBy', `${orderBy.field}=${orderBy.direction}`);
}
if (filters) {
for (const filter of filters) {
query.append('filter', `${filter.field}=${filter.value}`);
}
}
const res = await fetch(`${baseUrl}/v1/todos?${query}`, {
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
});
if (!res.ok) {
throw await ResponseError.fromResponse(res);
}
const data: TodoListResult = await res.json();
return data;
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TodoClient } from './TodoClient';
export { todoApiRef } from './types';
export type { TodoApi } from './types';
+71
View File
@@ -0,0 +1,71 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
export type TodoItem = {
/** The contents of the TODO comment */
text: string;
/** The tag used, e.g. TODO, FIXME */
tag: string;
/** References author, if any */
author?: string;
/** URL used to view the file */
viewUrl?: string;
/** The line number of the file that the TODO occurs at */
lineNumber?: number;
/** The path of the file containing the TODO within the repo */
repoFilePath?: string;
};
type Fields = 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath';
export type TodoListOptions = {
entity?: Entity;
offset?: number;
limit?: number;
orderBy?: {
field: Fields;
direction: 'asc' | 'desc';
};
filters?: {
field: Fields;
/** Value to filter by, with '*' used as wildcard */
value: string;
}[];
};
export type TodoListResult = {
items: TodoItem[];
totalCount: number;
offset: number;
limit: number;
};
export interface TodoApi {
listTodos(options: TodoListOptions): Promise<TodoListResult>;
}
export const todoApiRef = createApiRef<TodoApi>({
id: 'plugin.todo.api',
description: 'Lists TODOs',
});
@@ -0,0 +1,54 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { renderWithEffects } from '@backstage/test-utils';
import React from 'react';
import { TodoApi, todoApiRef } from '../../api';
import { TodoList } from './TodoList';
describe('TodoList', () => {
it('should render', async () => {
const mockApi: jest.Mocked<TodoApi> = {
listTodos: jest.fn().mockResolvedValue({
items: [
{
text: 'My TODO',
tag: 'FIXME',
viewUrl: 'https://example.com',
repoFilePath: '/my-file.js',
},
],
totalCount: 1,
limit: 10,
offset: 0,
}),
};
const mockEntity = { metadata: { name: 'mock' } } as Entity;
const rendered = await renderWithEffects(
<ApiProvider apis={ApiRegistry.with(todoApiRef, mockApi)}>
<EntityProvider entity={mockEntity}>
<TodoList />
</EntityProvider>
</ApiProvider>,
);
await expect(rendered.findByText('FIXME')).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,121 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Table,
TableColumn,
useApi,
OverflowTooltip,
Link,
ResponseErrorPanel,
} from '@backstage/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import React, { useState } from 'react';
import { todoApiRef } from '../../api';
import { TodoItem, TodoListOptions } from '../../api/types';
const PAGE_SIZE = 10;
const columns: TableColumn<TodoItem>[] = [
{
title: 'Tag',
field: 'tag',
filtering: false,
},
{
title: 'Text',
field: 'text',
width: '100%',
highlight: true,
render: ({ text }) => <OverflowTooltip text={text} />,
},
{
title: 'File',
field: 'repoFilePath',
width: '80%',
render: ({ viewUrl, repoFilePath }) =>
viewUrl ? (
<Link to={viewUrl} target="_blank">
<OverflowTooltip text={repoFilePath} />
</Link>
) : (
<OverflowTooltip text={repoFilePath} />
),
},
{
title: 'Author',
field: 'author',
width: '20%',
render: ({ author }) => <OverflowTooltip text={author} />,
},
];
export const TodoList = () => {
const { entity } = useEntity();
const todoApi = useApi(todoApiRef);
const [error, setError] = useState<Error>();
if (error) {
return <ResponseErrorPanel error={error} />;
}
return (
<Table<TodoItem>
title="TODOs"
options={{
search: false,
pageSize: PAGE_SIZE,
padding: 'dense',
sorting: true,
draggable: false,
paging: true,
filtering: true,
debounceInterval: 500,
filterCellStyle: { padding: '0 16px 0 20px' },
}}
columns={columns}
data={async query => {
try {
const page = query?.page ?? 0;
const pageSize = query?.pageSize ?? PAGE_SIZE;
const result = await todoApi.listTodos({
entity,
offset: page * pageSize,
limit: pageSize,
orderBy:
query?.orderBy &&
({
field: query.orderBy.field,
direction: query.orderDirection,
} as TodoListOptions['orderBy']),
filters: query?.filters?.map(filter => ({
field: filter.column.field!,
value: `*${filter.value}*`,
})) as TodoListOptions['filters'],
});
return {
data: result.items,
totalCount: result.totalCount,
page: Math.floor(result.offset / result.limit),
};
} catch (loadingError) {
setError(loadingError);
return { data: [], totalCount: 0, page: 0 };
}
}}
/>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TodoList } from './TodoList';
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as index from './index';
describe('imports', () => {
it('should export known symbols only', () => {
expect(index).toEqual({
todoPlugin: expect.any(Object),
todoApiRef: expect.any(Object),
EntityTodoContent: expect.any(Function),
});
});
});
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { todoApiRef } from './api';
export { todoPlugin, EntityTodoContent } from './plugin';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { todoPlugin } from './plugin';
describe('todo', () => {
it('should export plugin', () => {
expect(todoPlugin).toBeDefined();
});
});
+54
View File
@@ -0,0 +1,54 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createApiFactory,
createPlugin,
createComponentExtension,
discoveryApiRef,
identityApiRef,
} from '@backstage/core';
import { todoApiRef, TodoClient } from './api';
// import { rootRouteRef } from './routes';
export const todoPlugin = createPlugin({
id: 'todo',
apis: [
createApiFactory({
api: todoApiRef,
deps: {
identityApi: identityApiRef,
discoveryApi: discoveryApiRef,
},
factory({ identityApi, discoveryApi }) {
return new TodoClient({ identityApi, discoveryApi });
},
}),
],
routes: {
// root: rootRouteRef,
},
});
export const EntityTodoContent = todoPlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./components/TodoList').then(m => m.TodoList),
},
// TODO(Rugvip): Switch back to routable extension once apps are migrated
// mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core';
export const rootRouteRef = createRouteRef({
title: 'todo',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+33 -1
View File
@@ -10119,7 +10119,7 @@ commander@^5.0.0, commander@^5.1.0:
resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
commander@^6.1.0:
commander@^6.1.0, commander@^6.2.1:
version "6.2.1"
resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
@@ -12223,6 +12223,11 @@ envinfo@^7.7.4:
resolved "https://registry.npmjs.org/envinfo/-/envinfo-7.7.4.tgz#c6311cdd38a0e86808c1c9343f667e4267c4a320"
integrity sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ==
eol@^0.9.1:
version "0.9.1"
resolved "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz#f701912f504074be35c6117a5c4ade49cd547acd"
integrity sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==
err-code@^2.0.2:
version "2.0.3"
resolved "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9"
@@ -13918,6 +13923,11 @@ get-stdin@^6.0.0:
resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==
get-stdin@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==
get-stream@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@@ -16954,6 +16964,11 @@ json-to-pretty-yaml@^1.2.2:
remedial "^1.0.7"
remove-trailing-spaces "^1.0.6"
json2xml@^0.1.3:
version "0.1.3"
resolved "https://registry.npmjs.org/json2xml/-/json2xml-0.1.3.tgz#9ae7c220bedd7c66a668e26f7ac182f6704eca21"
integrity sha1-mufCIL7dfGamaOJvesGC9nBOyiE=
json3@^3.3.2:
version "3.3.3"
resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81"
@@ -17372,6 +17387,23 @@ ldapjs@^2.2.0:
vasync "^2.2.0"
verror "^1.8.1"
leasot@^11.5.0:
version "11.5.0"
resolved "https://registry.npmjs.org/leasot/-/leasot-11.5.0.tgz#a99eb4479618c9d2ea442a32ee006e5b9da4844d"
integrity sha512-L08QKlmofYIRs5gfOmhOtbEJUu6U/zFGvYpboPq34yAHQ2Oc/QznOw62noe29yRJLiV/XnIDS8vO2um1e1sikA==
dependencies:
async "^3.2.0"
chalk "^4.1.0"
commander "^6.2.1"
eol "^0.9.1"
get-stdin "^8.0.0"
globby "^11.0.1"
json2xml "^0.1.3"
lodash "^4.17.20"
log-symbols "^4.0.0"
strip-ansi "^6.0.0"
text-table "^0.2.0"
left-pad@^1.3.0:
version "1.3.0"
resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e"