diff --git a/.changeset/chatty-parents-search.md b/.changeset/chatty-parents-search.md new file mode 100644 index 0000000000..d9b1222d8f --- /dev/null +++ b/.changeset/chatty-parents-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Sort the table filter options by name. diff --git a/.changeset/unlucky-starfishes-vanish.md b/.changeset/unlucky-starfishes-vanish.md new file mode 100644 index 0000000000..3bb341bee9 --- /dev/null +++ b/.changeset/unlucky-starfishes-vanish.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-fossa': patch +--- + +Add a `FossaPage` that shows the license compliance status of all components in the catalog. +See the projects `Readme` on how to use it. diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index d061be6d60..2251b8304e 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -363,7 +363,7 @@ export function Table({ placeholder: 'All results', label: filter.column, multiple: filter.type === 'multiple-select', - items: [...extractDistinctValues(filter.column)].map(value => ({ + items: [...extractDistinctValues(filter.column)].sort().map(value => ({ label: value, value, })), diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 722d8d2cf3..049a1c25cb 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -75,3 +75,51 @@ spec: owner: CNCF lifecycle: experimental ``` + +## Other Components + +### FOSSA Overview Page + +The plugin provides an optional page that can be used to check the license compliance of all components. + +![FOSSA Overview](./docs/fossa-overview.png) + +Add it to your Backstage application: + +1. Install the FOSSA Plugin (see [Getting Started](#getting-started)). + +2. Register the page: + +```tsx +// packages/app/src/App.tsx + +import { FossaPage } from '@backstage/plugin-fossa'; + +// ... + +const routes = ( + + // ... + } /> + +); +``` + +3. (Optional) Add a Sidebar Icon: + +```tsx +// packages/app/src/components/Root/Root.tsx + +// ... + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + // ... + + // ... + + {children} + +); +``` diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx index 06c71c48b7..563c63faf5 100644 --- a/plugins/fossa/dev/index.tsx +++ b/plugins/fossa/dev/index.tsx @@ -14,15 +14,20 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { Content, Header, Page } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { EntityFossaCard } from '../src'; -import { FossaApi, fossaApiRef } from '../src/api'; -import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/useProjectName'; +import { EntityFossaCard, fossaPlugin } from '../src'; +import { FindingSummary, FossaApi, fossaApiRef } from '../src/api'; +import { FossaPage } from '../src/components/FossaPage'; +import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/getProjectName'; const entity = (name?: string) => ({ @@ -34,9 +39,16 @@ const entity = (name?: string) => }, name: name, }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'Group', namespace: 'default', name }, + }, + ], } as Entity); createDevApp() + .registerPlugin(fossaPlugin) .registerApi({ api: fossaApiRef, deps: {}, @@ -81,8 +93,63 @@ createDevApp() return undefined; } }, + getFindingSummaries: async () => { + await new Promise(r => setTimeout(r, 1000)); + + return new Map([ + [ + 'zero-deps', + { + timestamp: '2000-01-01T00:00:00Z', + projectUrl: '', + projectDefaultBranch: 'master', + issueCount: 0, + dependencyCount: 0, + }, + ], + [ + 'issues', + { + timestamp: '2001-01-01T00:00:00Z', + projectUrl: '', + projectDefaultBranch: 'develop', + issueCount: 10, + dependencyCount: 15, + }, + ], + [ + 'no-issues', + { + timestamp: '2002-01-01T00:00:00Z', + projectUrl: '', + projectDefaultBranch: 'master', + issueCount: 0, + dependencyCount: 5, + }, + ], + ]); + }, } as FossaApi), }) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + (({ + getEntities: async () => { + await new Promise(r => setTimeout(r, 1000)); + + return { + items: [ + entity('no-fossa'), + entity('zero-deps'), + entity('issues'), + entity('no-issues'), + ], + }; + }, + } as Partial) as any), + }) .addPage({ title: 'Entity Content', element: ( @@ -130,4 +197,8 @@ createDevApp() ), }) + .addPage({ + title: 'Catalog Overview', + element: , + }) .render(); diff --git a/plugins/fossa/docs/fossa-overview.png b/plugins/fossa/docs/fossa-overview.png new file mode 100644 index 0000000000..6298fb9557 Binary files /dev/null and b/plugins/fossa/docs/fossa-overview.png differ diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index fd94c6b906..2f656c476a 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -33,12 +33,15 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/core": "^0.7.5", + "@backstage/errors": "^0.1.1", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.5", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "cross-fetch": "^3.0.6", + "luxon": "^1.26.0", + "p-limit": "^3.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-use": "^15.3.3" diff --git a/plugins/fossa/src/api/FossaApi.ts b/plugins/fossa/src/api/FossaApi.ts index 84406e1b1a..15e8b4473e 100644 --- a/plugins/fossa/src/api/FossaApi.ts +++ b/plugins/fossa/src/api/FossaApi.ts @@ -30,5 +30,19 @@ export const fossaApiRef = createApiRef({ }); export type FossaApi = { + /** + * Get the finding summary for a list of projects + * + * @param projectTitles a list of project titles in FOSSA + */ + getFindingSummaries( + projectTitles: Array, + ): Promise>; + + /** + * Get the finding summary of a single project. + * + * @param projectTitle the project title in FOSSA + */ getFindingSummary(projectTitle: string): Promise; }; diff --git a/plugins/fossa/src/api/FossaClient.test.ts b/plugins/fossa/src/api/FossaClient.test.ts index 820210e2b6..29e5b50fcd 100644 --- a/plugins/fossa/src/api/FossaClient.test.ts +++ b/plugins/fossa/src/api/FossaClient.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlPatternDiscovery, IdentityApi } from '@backstage/core'; +import { IdentityApi, UrlPatternDiscovery } from '@backstage/core'; import { msw } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -52,138 +52,330 @@ describe('FossaClient', () => { }); }); - it('should report finding summary', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res( - ctx.json([ + describe('getFindingSummary', () => { + it('should report finding summary', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const expectedQuery = + 'count=1000&page=0&sort=title%2B&organizationId=8736&title=our-service'; + if (req.url.searchParams.toString() !== expectedQuery) { + return res( + ctx.status(500), + ctx.body( + `${req.url.searchParams.toString()} !== ${expectedQuery}`, + ), + ); + } + + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should report finding summary without licensing_issue_count', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'refs/master', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual({ + timestamp: '2020-01-01T00:00:00Z', + issueCount: 100, + dependencyCount: 160, + projectDefaultBranch: 'refs/master', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + } as FindingSummary); + }); + + it('should handle empty result', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.json([])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should ignore result with invalid title', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.json([{ title: 'our-service-2' }])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should skip organizationId', async () => { + client = new FossaClient({ discoveryApi, identityApi }); + + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const expectedQuery = + 'count=1000&page=0&sort=title%2B&title=our-service'; + if (req.url.searchParams.toString() !== expectedQuery) { + return res( + ctx.status(500), + ctx.body( + `${req.url.searchParams.toString()} !== ${expectedQuery}`, + ), + ); + } + + return res(ctx.json([])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should handle 404 status', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.status(404)); + }), + ); + + await expect(client.getFindingSummary('our-service')).rejects.toThrow(); + }); + }); + + describe('getFindingSummaries', () => { + it('should report finding summary', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const expectedQuery = + 'count=1000&page=0&sort=title%2B&organizationId=8736'; + if (req.url.searchParams.toString() !== expectedQuery) { + return res( + ctx.status(500), + ctx.body( + `${req.url.searchParams.toString()} !== ${expectedQuery}`, + ), + ); + } + + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + { + locator: 'custom+8736/our-service-2', + title: 'our-service-2', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + { + locator: 'custom+8736/our-service-3', + title: 'our-service-3', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); + + const summary = await client.getFindingSummaries([ + 'our-service', + 'our-service-2', + ]); + + expect(summary).toEqual( + new Map([ + [ + 'our-service', { - locator: 'custom+8736/our-service', - title: 'our-service', - default_branch: 'develop', - revisions: [ - { - updatedAt: '2020-01-01T00:00:00Z', - dependency_count: 160, - unresolved_licensing_issue_count: 5, - unresolved_issue_count: 100, - }, - ], + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', }, - ]), - ); - }), - ); - - const summary = await client.getFindingSummary('our-service'); - - expect(summary).toEqual({ - timestamp: '2020-01-01T00:00:00Z', - issueCount: 5, - dependencyCount: 160, - projectDefaultBranch: 'develop', - projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', - } as FindingSummary); - }); - - it('should report finding summary without licensing_issue_count', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res( - ctx.json([ + ], + [ + 'our-service-2', { - locator: 'custom+8736/our-service', - title: 'our-service', - default_branch: 'refs/master', - revisions: [ - { - updatedAt: '2020-01-01T00:00:00Z', - dependency_count: 160, - unresolved_issue_count: 100, - }, - ], + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service-2', }, - ]), - ); - }), - ); + ], + ]), + ); + }); - const summary = await client.getFindingSummary('our-service'); + it('should handle multiple pages', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + const page = req.url.searchParams.get('page'); - expect(summary).toEqual({ - timestamp: '2020-01-01T00:00:00Z', - issueCount: 100, - dependencyCount: 160, - projectDefaultBranch: 'refs/master', - projectUrl: 'https://app.fossa.com/projects/custom%2B8736%2Four-service', - } as FindingSummary); - }); + if (page === '0') { + return res( + ctx.json( + [...Array(1000)].map(() => ({ + locator: 'custom+8736/our-service', + title: 'our-service-2', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + })), + ), + ); + } - it('should handle empty result', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res(ctx.json([])); - }), - ); + return res( + ctx.json([ + { + locator: 'custom+8736/our-service', + title: 'our-service', + default_branch: 'develop', + revisions: [ + { + updatedAt: '2020-01-01T00:00:00Z', + dependency_count: 160, + unresolved_licensing_issue_count: 5, + unresolved_issue_count: 100, + }, + ], + }, + ]), + ); + }), + ); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummaries(['our-service']); - expect(summary).toBeUndefined(); - }); + expect(summary).toEqual( + new Map([ + [ + 'our-service', + { + timestamp: '2020-01-01T00:00:00Z', + issueCount: 5, + dependencyCount: 160, + projectDefaultBranch: 'develop', + projectUrl: + 'https://app.fossa.com/projects/custom%2B8736%2Four-service', + }, + ], + ]), + ); + }); - it('should ignore result with invalid title', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res(ctx.json([{ title: 'our-service-2' }])); - }), - ); + it('should handle empty result', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.json([])); + }), + ); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummaries([ + 'our-service', + 'our-service-2', + ]); - expect(summary).toBeUndefined(); - }); + expect(summary).toEqual(new Map()); + }); - it('should skip organizationId', async () => { - client = new FossaClient({ discoveryApi, identityApi }); + it('should handle 404 status', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (_req, res, ctx) => { + return res(ctx.status(404)); + }), + ); - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service', - ); - return res(ctx.status(404)); - }), - ); - - const summary = await client.getFindingSummary('our-service'); - - expect(summary).toBeUndefined(); - }); - - it('should handle 404 status', async () => { - server.use( - rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'count=1&sort=title+&title=our-service&organizationId=8736', - ); - return res(ctx.status(404)); - }), - ); - - const summary = await client.getFindingSummary('our-service'); - - expect(summary).toBeUndefined(); + await expect( + client.getFindingSummaries(['our-service', 'our-service-2']), + ).rejects.toThrow(); + }); }); }); diff --git a/plugins/fossa/src/api/FossaClient.ts b/plugins/fossa/src/api/FossaClient.ts index 5693895901..e8f3ecb7cd 100644 --- a/plugins/fossa/src/api/FossaClient.ts +++ b/plugins/fossa/src/api/FossaClient.ts @@ -15,13 +15,28 @@ */ import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { ResponseError } from '@backstage/errors'; import fetch from 'cross-fetch'; +import pLimit from 'p-limit'; import { FindingSummary, FossaApi } from './FossaApi'; +type FossaProjectsResponse = { + title: string; + default_branch: string; + locator: string; + revisions: Array<{ + updatedAt: string; + unresolved_licensing_issue_count: number; + unresolved_issue_count: number; + dependency_count: number; + }>; +}; + export class FossaClient implements FossaApi { discoveryApi: DiscoveryApi; identityApi: IdentityApi; organizationId?: string; + private readonly limit = pLimit(5); constructor({ discoveryApi, @@ -37,42 +52,94 @@ export class FossaClient implements FossaApi { this.organizationId = organizationId; } - private async callApi(path: string): Promise { + private async callApi( + path: string, + query: Record, + ): Promise { const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/fossa`; const idToken = await this.identityApi.getIdToken(); - const response = await fetch(`${apiUrl}/${path}`, { - headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, - }); - if (response.status === 200) { - return await response.json(); + const response = await fetch( + `${apiUrl}/${path}?${new URLSearchParams(query).toString()}`, + { + headers: idToken ? { Authorization: `Bearer ${idToken}` } : {}, + }, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); } - return undefined; + + return (await response.json()) as T; + } + + async *getProject( + projectTitles: Set, + ): AsyncIterable<{ title: string; summary: FindingSummary }> { + const pageSize = 1000; + + for (let page = 0; ; page++) { + const projects = await this.limit(() => + this.callApi('projects', { + count: pageSize, + page, + sort: 'title+', + ...(this.organizationId && { + organizationId: this.organizationId, + }), + ...(projectTitles.size === 1 && { + title: projectTitles.values().next().value, + }), + }), + ); + + for (const project of projects) { + if (projectTitles.has(project.title) && project.revisions.length > 0) { + const revision = project.revisions[0]; + yield { + title: project.title, + summary: { + timestamp: revision.updatedAt, + issueCount: + revision.unresolved_licensing_issue_count || + revision.unresolved_issue_count, + dependencyCount: revision.dependency_count, + projectDefaultBranch: project.default_branch, + projectUrl: `https://app.fossa.com/projects/${encodeURIComponent( + project.locator, + )}`, + }, + }; + } + } + + if (projects.length < pageSize) { + break; + } + } + } + + async getFindingSummaries( + projectTitles: string[], + ): Promise> { + const map = new Map(); + + if (projectTitles.length === 0) { + return map; + } + + for await (const { title, summary } of this.getProject( + new Set(projectTitles), + )) { + map.set(title, summary); + } + + return map; } async getFindingSummary( projectTitle: string, ): Promise { - const project = await this.callApi( - `projects?count=1&sort=title+&title=${projectTitle}${ - this.organizationId ? `&organizationId=${this.organizationId}` : '' - }`, - ); - - if (!project || project.length === 0 || project[0].title !== projectTitle) { - return undefined; - } - - const revision = project[0].revisions[0]; - return { - timestamp: revision.updatedAt, - issueCount: - revision.unresolved_licensing_issue_count || - revision.unresolved_issue_count, - dependencyCount: revision.dependency_count, - projectDefaultBranch: project[0].default_branch, - projectUrl: `https://app.fossa.com/projects/${encodeURIComponent( - project[0].locator, - )}`, - }; + const summaries = await this.getFindingSummaries([projectTitle]); + return summaries.get(projectTitle); } } diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx new file mode 100644 index 0000000000..0761c85da5 --- /dev/null +++ b/plugins/fossa/src/components/FossaCard/FossaCard.test.tsx @@ -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 { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { FossaApi, fossaApiRef } from '../../api'; +import { FossaCard } from './FossaCard'; + +describe('', () => { + const fossaApi: jest.Mocked = { + getFindingSummary: jest.fn(), + getFindingSummaries: jest.fn(), + }; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(fossaApiRef, fossaApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows missing annotation', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + }, + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Missing Annotation/i)).toBeInTheDocument(); + expect(getByText('fossa.io/project-name')).toBeInTheDocument(); + }); + + it('shows error', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockRejectedValue(new Error('My Error')); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Warning: My Error/i)).toBeInTheDocument(); + }); + + it('shows empty', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockResolvedValue(undefined); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect( + getByText(/There is no Fossa project with title 'my-name'./i), + ).toBeInTheDocument(); + }); + + it('shows fossa issues', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockResolvedValue({ + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 0, + dependencyCount: 10, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/Number of issues/i)).toBeInTheDocument(); + expect( + getByText( + (_, node) => + node?.textContent === + 'Based on 10 Dependencies on branch branch/default-branch.', + ), + ).toBeInTheDocument(); + }); + + it('warns about zero dependencies', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name', + annotations: { + 'fossa.io/project-name': 'my-name', + }, + }, + }; + + fossaApi.getFindingSummary.mockResolvedValue({ + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 0, + dependencyCount: 0, + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText(/No Dependencies/i)).toBeInTheDocument(); + expect( + getByText(/Please check your FOSSA project settings/i), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx index 4cc27f5d03..034ba57dfc 100644 --- a/plugins/fossa/src/components/FossaCard/FossaCard.tsx +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -17,20 +17,23 @@ import { EmptyState, InfoCard, + InfoCardVariants, MissingAnnotationEmptyState, Progress, + ResponseErrorPanel, useApi, } from '@backstage/core'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Grid, Tooltip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import React from 'react'; +import { DateTime } from 'luxon'; +import React, { PropsWithChildren } from 'react'; import { useAsync } from 'react-use'; import { fossaApiRef } from '../../api'; import { FOSSA_PROJECT_NAME_ANNOTATION, - useProjectName, -} from '../useProjectName'; + getProjectName, +} from '../getProjectName'; const useStyles = makeStyles(theme => ({ numberError: { @@ -65,109 +68,134 @@ const useStyles = makeStyles(theme => ({ }, })); -export const FossaCard = () => { +const Card = ({ + children, + disabled, + projectUrl, + variant = 'gridItem', +}: PropsWithChildren<{ + disabled?: boolean; + projectUrl?: string; + variant?: InfoCardVariants; +}>) => { + const classes = useStyles(); + + return ( + + {children} + + ); +}; +export const FossaCard = ({ variant }: { variant?: InfoCardVariants }) => { const { entity } = useEntity(); const fossaApi = useApi(fossaApiRef); - const projectTitle = useProjectName(entity); - - const { value, loading } = useAsync( + const projectTitle = getProjectName(entity); + const { value, loading, error } = useAsync( async () => projectTitle ? fossaApi.getFindingSummary(projectTitle) : undefined, [fossaApi, projectTitle], ); - const deepLink = value - ? { - title: 'View more', - link: value.projectUrl, - } - : undefined; - const classes = useStyles(); + if (error) { + return ( + + + + ); + } + + if (loading) { + return ( + + + + ); + } + + if (!projectTitle) { + return ( + + + + ); + } + + if (!value) { + return ( + + + + ); + } + return ( - <> - + - {loading && } - - {!loading && !projectTitle && ( - - )} - - {!loading && projectTitle && !value && ( - - )} - - {value && ( - +

0 || value.dependencyCount === 0 + ? classes.numberError + : classes.numberSuccess + } > - -

0 || value.dependencyCount === 0 - ? classes.numberError - : classes.numberSuccess - } - > - {value.issueCount} -

- {value.dependencyCount > 0 && ( -

Number of issues

- )} - {value.dependencyCount === 0 && ( -

- No Dependencies. -
- Please check your FOSSA project settings. -

- )} -
+ {value.issueCount} +

+ {value.dependencyCount > 0 && ( +

Number of issues

+ )} + {value.dependencyCount === 0 && ( +

+ No Dependencies. +
+ Please check your FOSSA project settings. +

+ )} +
- - Last analyzed on{' '} - {new Date(value.timestamp).toLocaleString('en-US', { - timeZone: 'UTC', - day: 'numeric', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - })} - - - Based on {value.dependencyCount} Dependencies on branch{' '} - - - {value.projectDefaultBranch} - - - . - - - )} -
- + + Last analyzed on{' '} + {DateTime.fromISO(value.timestamp).toLocaleString( + DateTime.DATETIME_MED, + )} + + + Based on {value.dependencyCount} Dependencies on branch{' '} + + {value.projectDefaultBranch} + + . + + + ); }; diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx new file mode 100644 index 0000000000..efab6ab091 --- /dev/null +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { FossaApi, fossaApiRef } from '../../api'; +import { FossaPage } from './FossaPage'; + +describe('', () => { + const catalogApi: jest.Mocked = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + getOriginLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + removeLocationById: jest.fn(), + }; + const fossaApi: jest.Mocked = { + getFindingSummary: jest.fn(), + getFindingSummaries: jest.fn(), + }; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(fossaApiRef, fossaApi).with( + catalogApiRef, + catalogApi, + ); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows fossa issues', async () => { + const entity0: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-0', + annotations: { + 'fossa.io/project-name': 'my-name-0', + }, + }, + }; + const entity1: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-1', + annotations: { + 'fossa.io/project-name': 'my-name-1', + }, + }, + }; + const entity2: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-2', + annotations: { + 'fossa.io/project-name': 'my-name-2', + }, + }, + }; + const entity3: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-name-3', + }, + }; + + catalogApi.getEntities.mockResolvedValue({ + items: [entity0, entity1, entity2, entity3], + }); + + fossaApi.getFindingSummaries.mockResolvedValue( + new Map([ + [ + 'my-name-1', + { + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 0, + dependencyCount: 0, + }, + ], + [ + 'my-name-2', + { + timestamp: '2000-01-01T00:00:00Z', + projectDefaultBranch: 'branch/default-branch', + projectUrl: 'http://…', + issueCount: 10, + dependencyCount: 10, + }, + ], + ]), + ); + + const { getByText, getAllByText } = await renderInTestApp( + + + , + ); + + expect(getAllByText(/Not configured/i)).toHaveLength(2); + expect(getByText(/No dependencies/i)).toBeInTheDocument(); + expect(getByText(/0 Issues/i)).toBeInTheDocument(); + expect(getByText(/10 Issues/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.tsx new file mode 100644 index 0000000000..4a5c1b9bff --- /dev/null +++ b/plugins/fossa/src/components/FossaPage/FossaPage.tsx @@ -0,0 +1,244 @@ +/* + * 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, + EntityName, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; +import { + Content, + Header, + Link, + Page, + StatusError, + StatusOK, + StatusWarning, + Table, + TableColumn, + TableFilter, + useApi, +} from '@backstage/core'; +import { + catalogApiRef, + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import { Tooltip } from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import { DateTime } from 'luxon'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { useAsync } from 'react-use'; +import { FindingSummary, fossaApiRef } from '../../api'; +import { getProjectName } from '../getProjectName'; + +type FossaRow = { + entity: Entity; + resolved: { + name: string; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + loading: boolean; + details?: FindingSummary; + }; +}; + +const columns: TableColumn[] = [ + { + title: 'Name', + field: 'resolved.name', + highlight: true, + width: 'auto', + render: ({ entity }) => ( + + ), + }, + { + title: 'Owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), + }, + { + title: 'Status', + field: 'resolved.details.issueCount', + // We interpret missing values as '0.5' to be sorted at the end of the projects with issues. + customSort: (a, b) => + (b?.resolved?.details?.issueCount ?? 0.5) - + (a?.resolved?.details?.issueCount ?? 0.5), + render: ({ resolved }) => { + if (resolved.loading) { + return ; + } else if (!resolved.details) { + return Not configured; + } else if (resolved.details.dependencyCount === 0) { + return No dependencies; + } else if (resolved.details.issueCount > 0) { + return {resolved.details.issueCount} Issues; + } + + return {resolved.details.issueCount} Issues; + }, + }, + { + title: 'Dependencies', + field: 'resolved.details.dependencyCount', + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return details?.dependencyCount; + }, + }, + { + title: 'Branch', + field: 'resolved.details.projectDefaultBranch', + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return details?.projectDefaultBranch; + }, + }, + { + title: 'Last Updated', + field: 'resolved.details.timestamp', + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return ( + details?.timestamp && ( + + + {DateTime.fromISO(details.timestamp).toRelative({ + locale: 'en', + })} + + + ) + ); + }, + }, + { + sorting: false, + render: ({ resolved: { loading, details } }) => { + if (loading) { + return ; + } + + return details && View in FOSSA; + }, + }, +]; + +const filters: TableFilter[] = [ + { column: 'Owner', type: 'multiple-select' }, + { column: 'Branch', type: 'select' }, +]; + +export const FossaPage = () => { + const catalogApi = useApi(catalogApiRef); + const fossaApi = useApi(fossaApiRef); + + // Get a list of all relevant entities + const { value: entities, loading: entitiesLoading } = useAsync(() => { + return catalogApi.getEntities({ + filter: { kind: 'Component' }, + fields: [ + 'kind', + 'metadata.namespace', + 'metadata.name', + 'metadata.annotations', + 'relations', + ], + }); + }); + + // get the project names of all entities. the idx of both lists match. + const projectNames = useMemo( + () => entities?.items?.map(getProjectName) ?? [], + [entities?.items], + ); + + // get the summary list + const { value: summaries, loading: summariesLoading } = useAsync( + async () => + await fossaApi.getFindingSummaries( + projectNames.filter(n => n !== undefined) as string[], + ), + [projectNames], + ); + + // compose the rows + const rows: FossaRow[] = useMemo( + () => + entities?.items?.map((entity, idx) => { + const projectName = projectNames[idx]; + const summary = projectName ? summaries?.get(projectName) : undefined; + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity, + resolved: { + name: formatEntityRefTitle(entity), + ownedByRelations, + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + loading: summariesLoading, + details: summary, + }, + }; + }) ?? [], + [projectNames, entities?.items, summaries, summariesLoading], + ); + + return ( + +
+ + + columns={columns} + data={rows} + filters={filters} + isLoading={entitiesLoading} + options={{ + pageSize: 20, + actionsColumnIndex: -1, + loadingType: 'linear', + padding: 'dense', + showEmptyDataSourceMessage: !entitiesLoading, + }} + /> + + + ); +}; diff --git a/plugins/fossa/src/components/FossaPage/index.ts b/plugins/fossa/src/components/FossaPage/index.ts new file mode 100644 index 0000000000..7d6481949e --- /dev/null +++ b/plugins/fossa/src/components/FossaPage/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 { FossaPage } from './FossaPage'; diff --git a/plugins/fossa/src/components/getProjectName.test.ts b/plugins/fossa/src/components/getProjectName.test.ts new file mode 100644 index 0000000000..93f5187890 --- /dev/null +++ b/plugins/fossa/src/components/getProjectName.test.ts @@ -0,0 +1,47 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { getProjectName } from './getProjectName'; + +describe('getProjectName', () => { + it('should extract the fossa project name', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'fossa.io/project-name': 'test', + }, + }, + }; + + expect(getProjectName(entity)).toBe('test'); + }); + + it('should return undefined', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + }, + }; + + expect(getProjectName(entity)).toBeUndefined(); + }); +}); diff --git a/plugins/fossa/src/components/useProjectName.ts b/plugins/fossa/src/components/getProjectName.ts similarity index 92% rename from plugins/fossa/src/components/useProjectName.ts rename to plugins/fossa/src/components/getProjectName.ts index 88379ca3fa..3f117dbbda 100644 --- a/plugins/fossa/src/components/useProjectName.ts +++ b/plugins/fossa/src/components/getProjectName.ts @@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model'; export const FOSSA_PROJECT_NAME_ANNOTATION = 'fossa.io/project-name'; -export const useProjectName = (entity: Entity): string | undefined => { +export const getProjectName = (entity: Entity): string | undefined => { return ( entity?.metadata.annotations?.[FOSSA_PROJECT_NAME_ANNOTATION] ?? undefined ); diff --git a/plugins/fossa/src/extensions.tsx b/plugins/fossa/src/extensions.tsx index 38573c2d1f..21e53996ba 100644 --- a/plugins/fossa/src/extensions.tsx +++ b/plugins/fossa/src/extensions.tsx @@ -14,8 +14,12 @@ * limitations under the License. */ -import { createComponentExtension } from '@backstage/core'; +import { + createComponentExtension, + createRoutableExtension, +} from '@backstage/core'; import { fossaPlugin } from './plugin'; +import { rootRoute } from './routes'; export const EntityFossaCard = fossaPlugin.provide( createComponentExtension({ @@ -24,3 +28,10 @@ export const EntityFossaCard = fossaPlugin.provide( }, }), ); + +export const FossaPage = fossaPlugin.provide( + createRoutableExtension({ + component: () => import('./components/FossaPage').then(m => m.FossaPage), + mountPoint: rootRoute, + }), +); diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts index f1b645c29b..929983bb6a 100644 --- a/plugins/fossa/src/index.ts +++ b/plugins/fossa/src/index.ts @@ -15,4 +15,4 @@ */ export { fossaPlugin } from './plugin'; -export { EntityFossaCard } from './extensions'; +export { EntityFossaCard, FossaPage } from './extensions'; diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts index 638be4a0bf..849769d42c 100644 --- a/plugins/fossa/src/plugin.ts +++ b/plugins/fossa/src/plugin.ts @@ -22,6 +22,7 @@ import { identityApiRef, } from '@backstage/core'; import { fossaApiRef, FossaClient } from './api'; +import { rootRoute } from './routes'; export const fossaPlugin = createPlugin({ id: 'fossa', @@ -41,4 +42,7 @@ export const fossaPlugin = createPlugin({ }), }), ], + routes: { + fossaOverview: rootRoute, + }, }); diff --git a/plugins/fossa/src/routes.ts b/plugins/fossa/src/routes.ts new file mode 100644 index 0000000000..6e8ce07b29 --- /dev/null +++ b/plugins/fossa/src/routes.ts @@ -0,0 +1,21 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +export const rootRoute = createRouteRef({ + id: 'fossa-overview', +});