+ );
+}
diff --git a/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx
new file mode 100644
index 0000000000..4c9ee9ed42
--- /dev/null
+++ b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.test.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 React from 'react';
+import { ProjectsComponent } from './ProjectsComponent';
+import { ThemeProvider } from '@material-ui/core';
+import { lightTheme } from '@backstage/theme';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import { rootRouteRef } from '../../routes';
+import {
+ setupRequestMockHandlers,
+ renderInTestApp,
+ TestApiRegistry,
+} from '@backstage/test-utils';
+import { CodeSceneApi, codesceneApiRef } from '../../api/api';
+import { ApiProvider } from '@backstage/core-app-api';
+import { FetchProjectsResponse, Analysis } from '../../api/types';
+
+describe('ProjectsComponent', () => {
+ const server = setupServer();
+ // Enable sane handlers for network requests
+ setupRequestMockHandlers(server);
+ let apis: TestApiRegistry;
+
+ // setup mock response
+ beforeEach(() => {
+ server.use(
+ rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
+ );
+
+ const projectsResponse: FetchProjectsResponse = {
+ page: 1,
+ max_pages: 1,
+ projects: [
+ {
+ id: 123,
+ name: 'test-project',
+ },
+ ],
+ };
+ const analysis: Analysis = {
+ id: 1,
+ name: 'test-project',
+ project_id: 123,
+ readable_analysis_time: '2022-03-22',
+ summary: {
+ unique_issue_ids: 0,
+ issues_filtered_as_outliers: 0,
+ entities: 0,
+ commits_with_issue_ids: 0,
+ authors_count: 0,
+ active_authors_count: 0,
+ issues_with_cycle_time: 0,
+ commits: 0,
+ issue_ids_matched_to_issues: 0,
+ issues_classed_as_defects: 0,
+ issues_with_cost: 0,
+ },
+ file_summary: [],
+ high_level_metrics: {
+ current_score: 0,
+ month_score: 0,
+ year_score: 0,
+ active_developers: 0,
+ lines_of_code: 0,
+ system_mastery: 0,
+ },
+ };
+ apis = TestApiRegistry.from([
+ codesceneApiRef,
+ {
+ fetchProjects: jest.fn(() => projectsResponse),
+ fetchLatestAnalysis: jest.fn(() => analysis),
+ } as unknown as CodeSceneApi,
+ ]);
+ });
+ it('should render', async () => {
+ const rendered = await renderInTestApp(
+
+
+
+
+ ,
+ {
+ mountedRoutes: {
+ '/codescene': rootRouteRef,
+ },
+ },
+ );
+ expect(await rendered.findByText('Projects')).toBeInTheDocument();
+ expect(await rendered.findByText('test-project')).toBeInTheDocument();
+ });
+});
diff --git a/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.tsx b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.tsx
new file mode 100644
index 0000000000..212ab6e746
--- /dev/null
+++ b/plugins/codescene/src/components/ProjectsComponent/ProjectsComponent.tsx
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 React from 'react';
+import {
+ Content,
+ ContentHeader,
+ Progress,
+ ItemCardHeader,
+} from '@backstage/core-components';
+import { useRouteRef, useApi } from '@backstage/core-plugin-api';
+import Alert from '@material-ui/lab/Alert';
+import useAsync from 'react-use/lib/useAsync';
+import { Link as RouterLink } from 'react-router-dom';
+import { rootRouteRef } from '../../routes';
+import { codesceneApiRef } from '../../api/api';
+import { Project, Analysis } from '../../api/types';
+import {
+ Grid,
+ Card,
+ CardActionArea,
+ Input,
+ makeStyles,
+ CardContent,
+ Chip,
+ CardActions,
+} from '@material-ui/core';
+
+const useStyles = makeStyles(() => ({
+ overflowXScroll: {
+ overflowX: 'scroll',
+ },
+}));
+
+function matchFilter(filter?: string): (entry: Project) => boolean {
+ const terms = filter
+ ?.toLocaleLowerCase('en-US')
+ .split(/\s/)
+ .map(e => e.trim())
+ .filter(Boolean);
+
+ if (!terms?.length) {
+ return () => true;
+ }
+
+ return entry => {
+ const text = `${entry.name} ${entry.description || ''}`.toLocaleLowerCase(
+ 'en-US',
+ );
+ return terms.every(term => text.includes(term));
+ };
+}
+
+function topLanguages(analysis: Analysis, coerceAtMost: number): string[] {
+ return analysis.file_summary
+ .sort((a, b) => b.code - a.code)
+ .map(fileSummary => fileSummary.language)
+ .slice(0, coerceAtMost);
+}
+
+type ProjectAndAnalysis = {
+ project: Project;
+ analysis?: Analysis;
+};
+
+export const ProjectsComponent = () => {
+ const routeRef = useRouteRef(rootRouteRef);
+ const codesceneApi = useApi(codesceneApiRef);
+
+ const classes = useStyles();
+ const [searchText, setSearchText] = React.useState('');
+ const {
+ value: projectsWithAnalyses,
+ loading,
+ error,
+ } = useAsync(async (): Promise> => {
+ const projects = (await codesceneApi.fetchProjects()).projects;
+
+ const promises = projects.map(project =>
+ codesceneApi.fetchLatestAnalysis(project.id),
+ );
+ // analyses associates project IDs with their latests analysis
+ const analyses: Record = await Promise.allSettled(
+ promises,
+ ).then(results => {
+ return results
+ .filter(result => result.status === 'fulfilled')
+ .map(result => result as PromiseFulfilledResult)
+ .map(result => result.value)
+ .reduce(
+ (acc, analysis) => ({ ...acc, [analysis.project_id]: analysis }),
+ {},
+ );
+ });
+ return projects.reduce(
+ (acc, project) => ({
+ ...acc,
+ [project.id]: {
+ project: project,
+ analysis: analyses[project.id],
+ },
+ }),
+ {},
+ );
+ }, []);
+
+ if (loading) {
+ return ;
+ } else if (error) {
+ return {error.message};
+ } else if (
+ !projectsWithAnalyses ||
+ Object.keys(projectsWithAnalyses).length === 0
+ ) {
+ return No projects found!;
+ }
+
+ const projects = Object.values(projectsWithAnalyses)
+ .map(p => p.project)
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ const cards = projects.filter(matchFilter(searchText)).map(project => {
+ const analysis = projectsWithAnalyses[project.id].analysis;
+ const subtitle = analysis
+ ? `Last analysis: ${analysis.readable_analysis_time} · Score: ${analysis.high_level_metrics.current_score} · Active authors: ${analysis.high_level_metrics.active_developers}`
+ : undefined;
+ const chips = analysis
+ ? topLanguages(analysis, 3).map(lang => (
+
+ ))
+ : undefined;
+ return (
+
+
+
+
+ {subtitle}
+ {chips}
+
+
+
+ );
+ });
+
+ return (
+
+
+ setSearchText(e.target.value)}
+ />
+
+ {cards}
+
+ );
+};
diff --git a/plugins/codescene/src/components/ProjectsComponent/index.ts b/plugins/codescene/src/components/ProjectsComponent/index.ts
new file mode 100644
index 0000000000..f89c0de697
--- /dev/null
+++ b/plugins/codescene/src/components/ProjectsComponent/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { ProjectsComponent } from './ProjectsComponent';
diff --git a/plugins/codescene/src/index.ts b/plugins/codescene/src/index.ts
new file mode 100644
index 0000000000..fa4406fb8f
--- /dev/null
+++ b/plugins/codescene/src/index.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ codescenePlugin,
+ CodescenePage,
+ CodesceneProjectDetailsPage,
+} from './plugin';
+import CodeSceneIconComponent from './assets/codescene.icon.svg';
+import { IconComponent } from '@backstage/core-plugin-api';
+
+/**
+ * @public
+ */
+export const CodeSceneIcon: IconComponent =
+ CodeSceneIconComponent as IconComponent;
diff --git a/plugins/codescene/src/plugin.test.ts b/plugins/codescene/src/plugin.test.ts
new file mode 100644
index 0000000000..1ffdb95b06
--- /dev/null
+++ b/plugins/codescene/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { codescenePlugin } from './plugin';
+
+describe('codescene', () => {
+ it('should export plugin', () => {
+ expect(codescenePlugin).toBeDefined();
+ });
+});
diff --git a/plugins/codescene/src/plugin.ts b/plugins/codescene/src/plugin.ts
new file mode 100644
index 0000000000..eb885589a2
--- /dev/null
+++ b/plugins/codescene/src/plugin.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ createPlugin,
+ createRoutableExtension,
+ createApiFactory,
+ discoveryApiRef,
+} from '@backstage/core-plugin-api';
+import { codesceneApiRef, CodeSceneClient } from './api/api';
+import { rootRouteRef, projectDetailsRouteRef } from './routes';
+
+/**
+ * @public
+ */
+export const codescenePlugin = createPlugin({
+ id: 'codescene',
+ apis: [
+ createApiFactory({
+ api: codesceneApiRef,
+ deps: { discoveryApi: discoveryApiRef },
+ factory: ({ discoveryApi }) => new CodeSceneClient({ discoveryApi }),
+ }),
+ ],
+ routes: {
+ root: rootRouteRef,
+ projectPage: projectDetailsRouteRef,
+ },
+});
+
+/**
+ * @public
+ */
+export const CodescenePage = codescenePlugin.provide(
+ createRoutableExtension({
+ name: 'CodescenePage',
+ component: () =>
+ import('./components/CodeScenePageComponent').then(
+ m => m.CodeScenePageComponent,
+ ),
+ mountPoint: rootRouteRef,
+ }),
+);
+
+/**
+ * @public
+ */
+export const CodesceneProjectDetailsPage = codescenePlugin.provide(
+ createRoutableExtension({
+ name: 'CodesceneProjectDetailsPage',
+ component: () =>
+ import('./components/CodeSceneProjectDetailsPage').then(
+ m => m.CodeSceneProjectDetailsPage,
+ ),
+ mountPoint: projectDetailsRouteRef,
+ }),
+);
diff --git a/plugins/codescene/src/routes.ts b/plugins/codescene/src/routes.ts
new file mode 100644
index 0000000000..2fd9a5ee38
--- /dev/null
+++ b/plugins/codescene/src/routes.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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-plugin-api';
+
+export const rootRouteRef = createRouteRef({
+ id: 'codescene',
+});
+
+export const projectDetailsRouteRef = createRouteRef({
+ id: 'codescene-project-page',
+ params: ['projectId'],
+});
diff --git a/plugins/codescene/src/setupTests.ts b/plugins/codescene/src/setupTests.ts
new file mode 100644
index 0000000000..9bb3e72355
--- /dev/null
+++ b/plugins/codescene/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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/scripts/api-extractor.ts b/scripts/api-extractor.ts
index 79f1f1eeb4..6dc5585958 100644
--- a/scripts/api-extractor.ts
+++ b/scripts/api-extractor.ts
@@ -243,6 +243,7 @@ const NO_WARNING_PACKAGES = [
'plugins/catalog-common',
'plugins/catalog-graph',
'plugins/catalog-react',
+ 'plugins/codescene',
'plugins/graphiql',
'plugins/org',
'plugins/periskop',