diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 8a3ebd180f..9050ad6f9d 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -43,6 +43,7 @@ JavaScript Kaewkasi Knex Kumar +Leasot Lerna Lindgren Lundberg @@ -91,6 +92,7 @@ Telenor Templater Templaters Thauer +todo Tolerations Tuite Trendyol diff --git a/microsite/data/plugins/todo.yaml b/microsite/data/plugins/todo.yaml new file mode 100644 index 0000000000..44128c40b2 --- /dev/null +++ b/microsite/data/plugins/todo.yaml @@ -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' diff --git a/microsite/static/img/todo-logo.png b/microsite/static/img/todo-logo.png new file mode 100644 index 0000000000..eae5401479 Binary files /dev/null and b/microsite/static/img/todo-logo.png differ diff --git a/packages/app/package.json b/packages/app/package.json index e05c0a0b5d..98b5f6dd70 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index fb56b40e68..d8883e7d18 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -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={} /> + } + /> ); @@ -323,6 +329,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( title="Code Insights" element={} /> + } + /> ); @@ -338,6 +349,11 @@ const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( title="Docs" element={} /> + } + /> ); diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 862d821ed6..29d19785bf 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -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'; diff --git a/packages/backend/package.json b/packages/backend/package.json index d3f967b3b3..dfbd45f200 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 81d0bb96d2..23739596e8 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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)); diff --git a/packages/backend/src/plugins/todo.ts b/packages/backend/src/plugins/todo.ts new file mode 100644 index 0000000000..84f2feac92 --- /dev/null +++ b/packages/backend/src/plugins/todo.ts @@ -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 { + const todoReader = TodoScmReader.fromConfig(config, { + logger, + reader, + }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const todoService = new TodoReaderService({ + todoReader, + catalogClient, + }); + + return await createRouter({ todoService }); +} diff --git a/plugins/todo-backend/.eslintrc.js b/plugins/todo-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/todo-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md new file mode 100644 index 0000000000..61ca5202d0 --- /dev/null +++ b/plugins/todo-backend/README.md @@ -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 { + 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'], + }), +}); +``` diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json new file mode 100644 index 0000000000..fd9bc46e1e --- /dev/null +++ b/plugins/todo-backend/package.json @@ -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" + ] +} diff --git a/plugins/todo-backend/src/index.test.ts b/plugins/todo-backend/src/index.test.ts new file mode 100644 index 0000000000..5b43267573 --- /dev/null +++ b/plugins/todo-backend/src/index.test.ts @@ -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), + }); + }); +}); diff --git a/plugins/todo-backend/src/index.ts b/plugins/todo-backend/src/index.ts new file mode 100644 index 0000000000..d0406483c8 --- /dev/null +++ b/plugins/todo-backend/src/index.ts @@ -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'; diff --git a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.test.ts b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.test.ts new file mode 100644 index 0000000000..83cc8b5ecf --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.test.ts @@ -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 { + return { + read: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), + } as jest.Mocked; +} + +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', + ); + }); +}); diff --git a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts new file mode 100644 index 0000000000..f13b0c05df --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts @@ -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(); + + static fromConfig(config: Config, options: Omit) { + 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 { + 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 { + 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(); + 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 }; + } +} diff --git a/plugins/todo-backend/src/lib/TodoReader/createTodoParser.test.ts b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.test.ts new file mode 100644 index 0000000000..0afe2c3420 --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.test.ts @@ -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([]); + }); +}); diff --git a/plugins/todo-backend/src/lib/TodoReader/createTodoParser.ts b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.ts new file mode 100644 index 0000000000..79d54e2769 --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.ts @@ -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 []; + } + }; +} diff --git a/plugins/todo-backend/src/lib/TodoReader/index.ts b/plugins/todo-backend/src/lib/TodoReader/index.ts new file mode 100644 index 0000000000..58982385c8 --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/index.ts @@ -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'; diff --git a/plugins/todo-backend/src/lib/TodoReader/types.ts b/plugins/todo-backend/src/lib/TodoReader/types.ts new file mode 100644 index 0000000000..a6f046d2ea --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/types.ts @@ -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; +} + +type TodoParserContext = { + content: string; + path: string; +}; + +type TodoParserResult = { + text: string; + tag: string; + author?: string; + lineNumber: number; +}; + +export type TodoParser = (ctx: TodoParserContext) => TodoParserResult[]; diff --git a/plugins/todo-backend/src/lib/index.ts b/plugins/todo-backend/src/lib/index.ts new file mode 100644 index 0000000000..85c100c959 --- /dev/null +++ b/plugins/todo-backend/src/lib/index.ts @@ -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'; diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts new file mode 100644 index 0000000000..dacc4118d9 --- /dev/null +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -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 { + 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 { + const mock = { + readTodos: jest.fn(), + }; + if (items) { + mock.readTodos.mockReturnValue({ items }); + } + return mock; +} + +function makeTodo(text: string, extra?: Partial): 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'`, + }), + ); + }); +}); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts new file mode 100644 index 0000000000..2f20aab0f1 --- /dev/null +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -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 { + 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)}`, + ); + } +} diff --git a/plugins/todo-backend/src/service/index.ts b/plugins/todo-backend/src/service/index.ts new file mode 100644 index 0000000000..cbcf591da5 --- /dev/null +++ b/plugins/todo-backend/src/service/index.ts @@ -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'; diff --git a/plugins/todo-backend/src/service/router.test.ts b/plugins/todo-backend/src/service/router.test.ts new file mode 100644 index 0000000000..13574d9381 --- /dev/null +++ b/plugins/todo-backend/src/service/router.test.ts @@ -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 = { + 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 [:][/]', + ), + ); + + 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', + ); + }); +}); diff --git a/plugins/todo-backend/src/service/router.ts b/plugins/todo-backend/src/service/router.ts new file mode 100644 index 0000000000..9c06e19a20 --- /dev/null +++ b/plugins/todo-backend/src/service/router.ts @@ -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 { + 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( + 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( + 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; +} diff --git a/plugins/todo-backend/src/service/types.ts b/plugins/todo-backend/src/service/types.ts new file mode 100644 index 0000000000..9216fe733d --- /dev/null +++ b/plugins/todo-backend/src/service/types.ts @@ -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; +} diff --git a/plugins/todo/.eslintrc.js b/plugins/todo/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/todo/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/todo/README.md b/plugins/todo/README.md new file mode 100644 index 0000000000..4165582b65 --- /dev/null +++ b/plugins/todo/README.md @@ -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 `()` suffix or trailing `/`. 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. | diff --git a/plugins/todo/dev/index.tsx b/plugins/todo/dev/index.tsx new file mode 100644 index 0000000000..71e5e36355 --- /dev/null +++ b/plugins/todo/dev/index.tsx @@ -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: ( + + + +
+ +
+ + + +
+
+
+ ), + title: 'Entity Todo Content', + icon: OfflineIcon, + }) + .addPage({ + element: ( + + +
+ +
+ + + +
+
+ ), + title: 'Backend Connected', + icon: OnlineIcon, + }) + .render(); diff --git a/plugins/todo/package.json b/plugins/todo/package.json new file mode 100644 index 0000000000..36faa1d9c0 --- /dev/null +++ b/plugins/todo/package.json @@ -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" + ] +} diff --git a/plugins/todo/src/api/TodoClient.ts b/plugins/todo/src/api/TodoClient.ts new file mode 100644 index 0000000000..9d9fff0144 --- /dev/null +++ b/plugins/todo/src/api/TodoClient.ts @@ -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 { + 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; + } +} diff --git a/plugins/todo/src/api/index.ts b/plugins/todo/src/api/index.ts new file mode 100644 index 0000000000..813a161469 --- /dev/null +++ b/plugins/todo/src/api/index.ts @@ -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'; diff --git a/plugins/todo/src/api/types.ts b/plugins/todo/src/api/types.ts new file mode 100644 index 0000000000..10c810592c --- /dev/null +++ b/plugins/todo/src/api/types.ts @@ -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; +} + +export const todoApiRef = createApiRef({ + id: 'plugin.todo.api', + description: 'Lists TODOs', +}); diff --git a/plugins/todo/src/components/TodoList/TodoList.test.tsx b/plugins/todo/src/components/TodoList/TodoList.test.tsx new file mode 100644 index 0000000000..2fb4f780bc --- /dev/null +++ b/plugins/todo/src/components/TodoList/TodoList.test.tsx @@ -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 = { + 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( + + + + + , + ); + + await expect(rendered.findByText('FIXME')).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/todo/src/components/TodoList/TodoList.tsx b/plugins/todo/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..101f1caac0 --- /dev/null +++ b/plugins/todo/src/components/TodoList/TodoList.tsx @@ -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[] = [ + { + title: 'Tag', + field: 'tag', + filtering: false, + }, + { + title: 'Text', + field: 'text', + width: '100%', + highlight: true, + render: ({ text }) => , + }, + { + title: 'File', + field: 'repoFilePath', + width: '80%', + render: ({ viewUrl, repoFilePath }) => + viewUrl ? ( + + + + ) : ( + + ), + }, + { + title: 'Author', + field: 'author', + width: '20%', + render: ({ author }) => , + }, +]; + +export const TodoList = () => { + const { entity } = useEntity(); + const todoApi = useApi(todoApiRef); + const [error, setError] = useState(); + + if (error) { + return ; + } + + return ( + + 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 }; + } + }} + /> + ); +}; diff --git a/plugins/todo/src/components/TodoList/index.ts b/plugins/todo/src/components/TodoList/index.ts new file mode 100644 index 0000000000..416a52fd7a --- /dev/null +++ b/plugins/todo/src/components/TodoList/index.ts @@ -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'; diff --git a/plugins/todo/src/index.test.ts b/plugins/todo/src/index.test.ts new file mode 100644 index 0000000000..f99455c18b --- /dev/null +++ b/plugins/todo/src/index.test.ts @@ -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), + }); + }); +}); diff --git a/plugins/todo/src/index.ts b/plugins/todo/src/index.ts new file mode 100644 index 0000000000..be740259a8 --- /dev/null +++ b/plugins/todo/src/index.ts @@ -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'; diff --git a/plugins/todo/src/plugin.test.ts b/plugins/todo/src/plugin.test.ts new file mode 100644 index 0000000000..ad07c17207 --- /dev/null +++ b/plugins/todo/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/todo/src/plugin.ts b/plugins/todo/src/plugin.ts new file mode 100644 index 0000000000..6dc06a7c34 --- /dev/null +++ b/plugins/todo/src/plugin.ts @@ -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, + }), +); diff --git a/plugins/todo/src/routes.ts b/plugins/todo/src/routes.ts new file mode 100644 index 0000000000..f34eea2391 --- /dev/null +++ b/plugins/todo/src/routes.ts @@ -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', +}); diff --git a/plugins/todo/src/setupTests.ts b/plugins/todo/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/todo/src/setupTests.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index 1d6a846928..bffd89a402 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"