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"