Merge pull request #4911 from backstage/rugvip/todo
plugins: add todo and todo-backend
This commit is contained in:
@@ -43,6 +43,7 @@ JavaScript
|
||||
Kaewkasi
|
||||
Knex
|
||||
Kumar
|
||||
Leasot
|
||||
Lerna
|
||||
Lindgren
|
||||
Lundberg
|
||||
@@ -91,6 +92,7 @@ Telenor
|
||||
Templater
|
||||
Templaters
|
||||
Thauer
|
||||
todo
|
||||
Tolerations
|
||||
Tuite
|
||||
Trendyol
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -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'],
|
||||
}),
|
||||
});
|
||||
```
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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. |
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user