From 75cfee5688850b432d79a112ea90bdee3c6b0450 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sat, 19 Nov 2022 16:43:29 -0600 Subject: [PATCH] Added linguist frontend, backend, and common plugins Signed-off-by: Andre Wanlin Removed refresh and permissions Signed-off-by: Andre Wanlin Small database refactor and improved naming Signed-off-by: Andre Wanlin Refactor stringifyEntityRef usage Signed-off-by: Andre Wanlin Fixed up yarn.lock Signed-off-by: Andre Wanlin --- .changeset/serious-horses-juggle.md | 7 + packages/app/package.json | 2 + .../app/src/components/catalog/EntityPage.tsx | 12 ++ packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + packages/backend/src/plugins/linguist.ts | 40 ++++ .../components/artist-lookup-component.yaml | 2 + plugins/linguist-backend/.eslintrc.js | 1 + plugins/linguist-backend/README.md | 94 ++++++++++ plugins/linguist-backend/api-report.md | 86 +++++++++ plugins/linguist-backend/config.d.ts | 27 +++ .../migrations/20221115_init.js | 67 +++++++ plugins/linguist-backend/package.json | 57 ++++++ .../src/api/LinguistBackendApi.ts | 173 ++++++++++++++++++ plugins/linguist-backend/src/api/index.ts | 17 ++ .../src/db/LinguistBackendDatabase.ts | 103 +++++++++++ plugins/linguist-backend/src/db/index.ts | 18 ++ plugins/linguist-backend/src/index.ts | 20 ++ plugins/linguist-backend/src/run.ts | 33 ++++ .../src/service/router.test.ts | 83 +++++++++ .../linguist-backend/src/service/router.ts | 108 +++++++++++ .../src/service/standaloneServer.ts | 76 ++++++++ plugins/linguist-backend/src/setupTests.ts | 17 ++ plugins/linguist-common/.eslintrc.js | 1 + plugins/linguist-common/README.md | 3 + plugins/linguist-common/api-report.md | 50 +++++ plugins/linguist-common/package.json | 35 ++++ plugins/linguist-common/src/constants.ts | 18 ++ plugins/linguist-common/src/index.ts | 18 ++ plugins/linguist-common/src/setupTests.ts | 16 ++ plugins/linguist-common/src/types.ts | 54 ++++++ plugins/linguist/.eslintrc.js | 1 + plugins/linguist/README.md | 88 +++++++++ plugins/linguist/api-report.md | 21 +++ plugins/linguist/dev/index.tsx | 27 +++ plugins/linguist/docs/linguist-no-data.png | Bin 0 -> 12616 bytes plugins/linguist/docs/linguist-no-refresh.png | Bin 0 -> 15731 bytes plugins/linguist/docs/linguist-with-data.png | Bin 0 -> 22018 bytes plugins/linguist/package.json | 63 +++++++ plugins/linguist/src/api/LinguistApi.ts | 26 +++ plugins/linguist/src/api/LinguistClient.ts | 59 ++++++ plugins/linguist/src/api/index.ts | 18 ++ .../components/LinguistCard/LinguistCard.tsx | 153 ++++++++++++++++ .../src/components/LinguistCard/index.ts | 17 ++ plugins/linguist/src/hooks/index.ts | 17 ++ plugins/linguist/src/hooks/useLanguages.ts | 41 +++++ plugins/linguist/src/index.ts | 20 ++ plugins/linguist/src/plugin.test.ts | 22 +++ plugins/linguist/src/plugin.ts | 53 ++++++ plugins/linguist/src/setupTests.ts | 18 ++ yarn.lock | 130 +++++++++++-- 51 files changed, 2005 insertions(+), 11 deletions(-) create mode 100644 .changeset/serious-horses-juggle.md create mode 100644 packages/backend/src/plugins/linguist.ts create mode 100644 plugins/linguist-backend/.eslintrc.js create mode 100644 plugins/linguist-backend/README.md create mode 100644 plugins/linguist-backend/api-report.md create mode 100644 plugins/linguist-backend/config.d.ts create mode 100644 plugins/linguist-backend/migrations/20221115_init.js create mode 100644 plugins/linguist-backend/package.json create mode 100644 plugins/linguist-backend/src/api/LinguistBackendApi.ts create mode 100644 plugins/linguist-backend/src/api/index.ts create mode 100644 plugins/linguist-backend/src/db/LinguistBackendDatabase.ts create mode 100644 plugins/linguist-backend/src/db/index.ts create mode 100644 plugins/linguist-backend/src/index.ts create mode 100644 plugins/linguist-backend/src/run.ts create mode 100644 plugins/linguist-backend/src/service/router.test.ts create mode 100644 plugins/linguist-backend/src/service/router.ts create mode 100644 plugins/linguist-backend/src/service/standaloneServer.ts create mode 100644 plugins/linguist-backend/src/setupTests.ts create mode 100644 plugins/linguist-common/.eslintrc.js create mode 100644 plugins/linguist-common/README.md create mode 100644 plugins/linguist-common/api-report.md create mode 100644 plugins/linguist-common/package.json create mode 100644 plugins/linguist-common/src/constants.ts create mode 100644 plugins/linguist-common/src/index.ts create mode 100644 plugins/linguist-common/src/setupTests.ts create mode 100644 plugins/linguist-common/src/types.ts create mode 100644 plugins/linguist/.eslintrc.js create mode 100644 plugins/linguist/README.md create mode 100644 plugins/linguist/api-report.md create mode 100644 plugins/linguist/dev/index.tsx create mode 100644 plugins/linguist/docs/linguist-no-data.png create mode 100644 plugins/linguist/docs/linguist-no-refresh.png create mode 100644 plugins/linguist/docs/linguist-with-data.png create mode 100644 plugins/linguist/package.json create mode 100644 plugins/linguist/src/api/LinguistApi.ts create mode 100644 plugins/linguist/src/api/LinguistClient.ts create mode 100644 plugins/linguist/src/api/index.ts create mode 100644 plugins/linguist/src/components/LinguistCard/LinguistCard.tsx create mode 100644 plugins/linguist/src/components/LinguistCard/index.ts create mode 100644 plugins/linguist/src/hooks/index.ts create mode 100644 plugins/linguist/src/hooks/useLanguages.ts create mode 100644 plugins/linguist/src/index.ts create mode 100644 plugins/linguist/src/plugin.test.ts create mode 100644 plugins/linguist/src/plugin.ts create mode 100644 plugins/linguist/src/setupTests.ts diff --git a/.changeset/serious-horses-juggle.md b/.changeset/serious-horses-juggle.md new file mode 100644 index 0000000000..13be1d9715 --- /dev/null +++ b/.changeset/serious-horses-juggle.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-linguist': minor +'@backstage/plugin-linguist-backend': minor +'@backstage/plugin-linguist-common': minor +--- + +Introduced the Linguist plugin, checkout the plugin's `README.md` for more details! diff --git a/packages/app/package.json b/packages/app/package.json index 80ee11ea15..8c26254268 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -42,6 +42,8 @@ "@backstage/plugin-kubernetes": "workspace:^", "@backstage/plugin-lighthouse": "workspace:^", "@backstage/plugin-microsoft-calendar": "workspace:^", + "@backstage/plugin-linguist": "workspace:^", + "@backstage/plugin-linguist-common": "workspace:^", "@backstage/plugin-newrelic": "workspace:^", "@backstage/plugin-newrelic-dashboard": "workspace:^", "@backstage/plugin-org": "workspace:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3ab0e83238..9118347414 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -157,6 +157,10 @@ import { LightBox, } from '@backstage/plugin-techdocs-module-addons-contrib'; import { EntityCostInsightsContent } from '@backstage/plugin-cost-insights'; +import { + isLinguistAvailable, + EntityLinguistCard, +} from '@backstage/plugin-linguist'; const customEntityFilterKind = ['Component', 'API', 'System']; @@ -406,6 +410,14 @@ const overviewContent = ( + + + + + + + + diff --git a/packages/backend/package.json b/packages/backend/package.json index 3678c1f9ae..776aa18862 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -50,6 +50,7 @@ "@backstage/plugin-kafka-backend": "workspace:^", "@backstage/plugin-kubernetes-backend": "workspace:^", "@backstage/plugin-lighthouse-backend": "workspace:^", + "@backstage/plugin-linguist-backend": "workspace:^", "@backstage/plugin-permission-backend": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b68b162e52..eb2befca65 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -63,6 +63,7 @@ import permission from './plugins/permission'; import playlist from './plugins/playlist'; import adr from './plugins/adr'; import lighthouse from './plugins/lighthouse'; +import linguist from './plugins/linguist'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -154,6 +155,7 @@ async function main() { const eventBasedEntityProviders = await catalogEventBasedProviders( catalogEnv, ); + const linguistEnv = useHotMemoize(module, () => createEnv('linguist')); const apiRouter = Router(); apiRouter.use( @@ -180,6 +182,7 @@ async function main() { apiRouter.use('/playlist', await playlist(playlistEnv)); apiRouter.use('/explore', await explore(exploreEnv)); apiRouter.use('/adr', await adr(adrEnv)); + apiRouter.use('/linguist', await linguist(linguistEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/linguist.ts b/packages/backend/src/plugins/linguist.ts new file mode 100644 index 0000000000..d8e56f658b --- /dev/null +++ b/packages/backend/src/plugins/linguist.ts @@ -0,0 +1,40 @@ +/* + * 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 { TaskScheduleDefinition } from '@backstage/backend-tasks'; +import { createRouter } from '@backstage/plugin-linguist-backend'; +import { Router } from 'express'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const schedule: TaskScheduleDefinition = { + frequency: { minutes: 2 }, + timeout: { minutes: 15 }, + initialDelay: { seconds: 15 }, + }; + + return createRouter({ + logger: env.logger, + config: env.config, + reader: env.reader, + discovery: env.discovery, + database: env.database, + scheduler: env.scheduler, + schedule: schedule, + }); +} diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index c0022762b4..a3a3780c84 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -28,6 +28,8 @@ metadata: - url: https://example.com/alert title: Alerts icon: alert + annotations: + backstage.io/linguist: 'https://github.com/backstage/backstage/tree/master/plugins/playlist' spec: type: service lifecycle: experimental diff --git a/plugins/linguist-backend/.eslintrc.js b/plugins/linguist-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/linguist-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/linguist-backend/README.md b/plugins/linguist-backend/README.md new file mode 100644 index 0000000000..d3c39a5fe7 --- /dev/null +++ b/plugins/linguist-backend/README.md @@ -0,0 +1,94 @@ +# Linguist Backend + +Welcome to the Linguist backend plugin! This plugin provides data for the Linguist frontend features. + +## Setup + +The following sections will help you get the Linguist Backend plugin setup and running. + +### Up and Running + +Here's how to get the backend up and running: + +1. First we need to add the `@backstage/plugin-linguist-backend` package to your backend: + + ```sh + # From the Backstage root directory + cd packages/backend + yarn add @backstage/plugin-linguist-backend + ``` + +2. Then we will create a new file named `packages/backend/src/plugins/linguist.ts`, and add the + following to it: + + ```ts + import { TaskScheduleDefinition } from '@backstage/backend-tasks'; + import { createRouter } from '@backstage/plugin-linguist-backend'; + import { Router } from 'express'; + import type { PluginEnvironment } from '../types'; + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { + const schedule: TaskScheduleDefinition = { + frequency: { hours: 24 }, + timeout: { minutes: 30 }, + initialDelay: { seconds: 90 }, + }; + + return createRouter({ + logger: env.logger, + config: env.config, + reader: env.reader, + discovery: env.discovery, + database: env.database, + scheduler: env.scheduler, + schedule: schedule, + }); + } + ``` + +3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`: + + ```ts + import linguist from './plugins/linguist'; + // ... + async function main() { + // ... + // Add this line under the other lines that follow the useHotMemoize pattern + const linguistEnv = useHotMemoize(module, () => createEnv('linguist')); + // ... + // Insert this line under the other lines that add their routers to apiRouter in the same way + apiRouter.use('/linguist', await linguist(linguistEnv)); + ``` + +4. Now run `yarn start-backend` from the repo root +5. Finally open `http://localhost:7007/api/linguist/health` in a browser and it should return `{"status":"ok"}` + +## Scheduling + +The Linguist backend can be configured to generate the language breakdown for all the entities with the linguist annotation. This is done by passing a `TaskScheduleDefinition` to the `createRouter` function like in Step 2 of the [Up and Running](#up-and-running) documentation. + +Here's another example of the `TaskScheduleDefinition` that will run the language breakdown processing every 12 hours: + +```ts +const schedule: TaskScheduleDefinition = { + frequency: { hours: 12 }, + timeout: { minutes: 15 }, + initialDelay: { seconds: 10 }, +}; +``` + +The default setup will only generate the language breakdown for entities with the linguist annotation that have not been generated yet. If you want this process to also refresh the data you can do so by adding the following configuration to your `app-config.yaml`: + +```yaml +linguist: + age: 15 # Days +``` + +With the configuration setup like this if the language breakdown is older than 15 days it will get regenerated. It's recommended that if you choose to use this configuration to set it to a large value - 30, 90, or 180 - as this data generally does not change drastically. + +## Links + +- [Frontend part of the plugin](../linguist/README.md) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/linguist-backend/api-report.md b/plugins/linguist-backend/api-report.md new file mode 100644 index 0000000000..26656416df --- /dev/null +++ b/plugins/linguist-backend/api-report.md @@ -0,0 +1,86 @@ +## API Report File for "@backstage/plugin-linguist-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import { EntitiesOverview } from '@backstage/plugin-linguist-common'; +import { EntityResults } from '@backstage/plugin-linguist-common'; +import express from 'express'; +import { Knex } from 'knex'; +import { Languages } from '@backstage/plugin-linguist-common'; +import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { ProcessedEntity } from '@backstage/plugin-linguist-common'; +import { TaskScheduleDefinition } from '@backstage/backend-tasks'; +import { UrlReader } from '@backstage/backend-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export class LinguistBackendApi { + constructor( + config: Config, + logger: Logger, + store: LinguistBackendStore, + urlReader: UrlReader, + discovery: PluginEndpointDiscovery, + ); + // (undocumented) + getEntitiesOverview(): Promise; + // (undocumented) + getEntityLanguages(entityRef: string): Promise; + // (undocumented) + processEntities(): Promise; + // (undocumented) + processEntity(entityRef: string, url: string): Promise; +} + +// @public (undocumented) +export class LinguistBackendDatabase implements LinguistBackendStore { + constructor(db: Knex); + // (undocumented) + static create(knex: Knex): Promise; + // (undocumented) + getEntityResults(entityRef: string): Promise; + // (undocumented) + getProcessedEntities(): Promise; + // (undocumented) + insertEntityResults(entityLanguages: EntityResults): Promise; +} + +// @public (undocumented) +export interface LinguistBackendStore { + // (undocumented) + getEntityResults(entityRef: string): Promise; + // (undocumented) + getProcessedEntities(): Promise; + // (undocumented) + insertEntityResults(entityLanguages: EntityResults): Promise; +} + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + linguistBackendApi?: LinguistBackendApi; + // (undocumented) + logger: Logger; + // (undocumented) + reader: UrlReader; + // (undocumented) + schedule?: TaskScheduleDefinition; + // (undocumented) + scheduler?: PluginTaskScheduler; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/linguist-backend/config.d.ts b/plugins/linguist-backend/config.d.ts new file mode 100644 index 0000000000..8e0133c90b --- /dev/null +++ b/plugins/linguist-backend/config.d.ts @@ -0,0 +1,27 @@ +/* + * 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 interface Config { + /** Configuration options for the linguist-backend plugin */ + linguist?: { + /** + * The age of an entity's languages before a refresh occurs, + * an age of 30 would mean the languages would be regenerated after 30 days. + * The default is 0 meaning languages won't be refreshed once generated. + */ + age: number; + }; +} diff --git a/plugins/linguist-backend/migrations/20221115_init.js b/plugins/linguist-backend/migrations/20221115_init.js new file mode 100644 index 0000000000..9eb0a03a90 --- /dev/null +++ b/plugins/linguist-backend/migrations/20221115_init.js @@ -0,0 +1,67 @@ +/* + * 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. + */ + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + // Note for the reader: the knex increments types automatically make it a + // primary column, whether you like it or not. That's why the id column is + // not marked as primary as one might have expected; it's only used for + // lookups by ID. Because, SQLite and MySQL don't return RETURNING on + // inserts ... so we want a manually generated key for lookups (an uuid), + // and also an index for ordering guarantees :) + await knex.schema.createTable('entity_result', table => { + table.comment('Table containing results from running Linguist'); + table + .bigIncrements('index') + .notNullable() + .comment('An insert counter to ensure ordering'); + table.uuid('id').notNullable().comment('The ID of the Linguist result'); + table + .text('entity_ref') + .unique() + .notNullable() + .comment('The entity ref that this Linguist result applies to'); + table + .text('languages') + .notNullable() + .comment('The results json as a string'); + table + .dateTime('created_at') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this entry was created'); + table + .dateTime('processed_date') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this entity was processed'); + table.index('index', 'entity_result_index_idx'); + table.index('entity_ref', 'entity_result_entity_ref_idx'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entity_result', table => { + table.dropIndex([], 'entity_result_index_idx'); + table.dropIndex([], 'entity_result_entity_ref_idx'); + }); + await knex.schema.dropTable('entity_result'); +}; diff --git a/plugins/linguist-backend/package.json b/plugins/linguist-backend/package.json new file mode 100644 index 0000000000..b79a97decd --- /dev/null +++ b/plugins/linguist-backend/package.json @@ -0,0 +1,57 @@ +{ + "name": "@backstage/plugin-linguist-backend", + "version": "0.0.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" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-tasks": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-linguist-common": "workspace:^", + "@types/express": "*", + "express": "^4.18.1", + "express-promise-router": "^4.1.0", + "fs-extra": "^10.0.0", + "knex": "^2.0.0", + "linguist-js": "^2.5.3", + "luxon": "^2.0.2", + "node-fetch": "^2.6.7", + "uuid": "^8.3.2", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/supertest": "^2.0.8", + "msw": "^0.49.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist", + "config.d.ts", + "migrations/**/*.{js,d.ts}" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/linguist-backend/src/api/LinguistBackendApi.ts b/plugins/linguist-backend/src/api/LinguistBackendApi.ts new file mode 100644 index 0000000000..fdabea6d6c --- /dev/null +++ b/plugins/linguist-backend/src/api/LinguistBackendApi.ts @@ -0,0 +1,173 @@ +/* + * 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 { + EntitiesOverview, + EntityResults, + Language, + Languages, +} from '@backstage/plugin-linguist-common'; +import { + CATALOG_FILTER_EXISTS, + CatalogClient, + GetEntitiesRequest, +} from '@backstage/catalog-client'; +import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common'; + +import { Config } from '@backstage/config'; +import { DateTime } from 'luxon'; +import { LINGUIST_ANNOTATION } from '@backstage/plugin-linguist-common'; +import { LinguistBackendStore } from '../db'; +import { Logger } from 'winston'; +import fs from 'fs-extra'; +import linguist from 'linguist-js'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { assertError } from '@backstage/errors'; + +/** @public */ +export class LinguistBackendApi { + public constructor( + private readonly config: Config, + private readonly logger: Logger, + private readonly store: LinguistBackendStore, + private readonly urlReader: UrlReader, + private readonly discovery: PluginEndpointDiscovery, + ) {} + + public async getEntityLanguages(entityRef: string): Promise { + this.logger?.debug(`Getting languages for entity "${entityRef}"`); + + return this.store.getEntityResults(entityRef); + } + + public async processEntity(entityRef: string, url: string): Promise { + this.logger?.info( + `Processing languages for entity ${entityRef} from ${url}`, + ); + + const readTreeResponse = await this.urlReader.readTree(url); + const dir = await readTreeResponse.dir(); + + const results = await linguist(dir); + + try { + const totalBytes = results.languages.bytes; + const langResults = results.languages.results; + + const breakdown: Language[] = []; + for (const key in langResults) { + if (Object.prototype.hasOwnProperty.call(langResults, key)) { + const lang: Language = { + name: key, + percentage: +((langResults[key].bytes / totalBytes) * 100).toFixed( + 2, + ), + bytes: langResults[key].bytes, + type: langResults[key].type, + color: langResults[key].color, + }; + breakdown.push(lang); + } + } + + const languages: Languages = { + languageCount: results.languages.count, + totalBytes: totalBytes, + processedDate: new Date().toISOString(), + breakdown: breakdown, + }; + + const entityResults: EntityResults = { + entityRef: entityRef, + results: languages, + }; + + return await this.store.insertEntityResults(entityResults); + } finally { + this.logger?.info(`Cleaning up files from ${dir}`); + await fs.remove(dir); + } + } + + public async processEntities() { + this.logger?.info('Processing applicable entities through Linguist'); + + const eo = await this.getEntitiesOverview(); + this.logger?.info( + `Entities overview: Entity: ${eo.entityCount}, Processed: ${eo.processedCount}, Pending: ${eo.pendingCount}, Stale ${eo.staleCount}`, + ); + + const entities = eo.filteredEntities; + for (const key in entities) { + if (Object.prototype.hasOwnProperty.call(entities, key)) { + const entityRef = stringifyEntityRef(entities[key]); + + const url = + entities[key].metadata.annotations?.[LINGUIST_ANNOTATION] ?? ''; + + try { + await this.processEntity(entityRef, url); + } catch (e) { + assertError(e); + this.logger.error( + `Unable to process "${entityRef}" using "${url}", ${e.message}`, + ); + } + } + } + } + + public async getEntitiesOverview(): Promise { + this.logger?.debug('Getting pending entities'); + const age = this.config.getOptionalNumber('linguist.age') ?? 0; + const catalogApi = new CatalogClient({ discoveryApi: this.discovery }); + const request: GetEntitiesRequest = { + filter: { + kind: ['API', 'Component', 'Template'], + [`metadata.annotations.${LINGUIST_ANNOTATION}`]: CATALOG_FILTER_EXISTS, + }, + fields: ['kind', 'metadata'], + }; + + const response = await catalogApi.getEntities(request); + const entities = response.items; + + const processedEntities = await this.store.getProcessedEntities(); + + const staleEntities = processedEntities.filter(pe => { + if (age === 0) return false; + const staleDate = DateTime.now().minus({ days: age }); + return DateTime.fromJSDate(pe.processed_date) >= staleDate; + }); + + const filteredEntities = entities.filter(en => { + return processedEntities.every(pe => { + const entityRef = stringifyEntityRef(en); + return pe.entityRef !== entityRef; + }); + }); + + const entitiesOverview: EntitiesOverview = { + entityCount: entities.length, + processedCount: processedEntities.length, + staleCount: staleEntities.length, + pendingCount: filteredEntities.length, + filteredEntities: filteredEntities, + }; + + return entitiesOverview; + } +} diff --git a/plugins/linguist-backend/src/api/index.ts b/plugins/linguist-backend/src/api/index.ts new file mode 100644 index 0000000000..a88fa051d4 --- /dev/null +++ b/plugins/linguist-backend/src/api/index.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. + */ + +export { LinguistBackendApi } from './LinguistBackendApi'; diff --git a/plugins/linguist-backend/src/db/LinguistBackendDatabase.ts b/plugins/linguist-backend/src/db/LinguistBackendDatabase.ts new file mode 100644 index 0000000000..8faa5c3ff0 --- /dev/null +++ b/plugins/linguist-backend/src/db/LinguistBackendDatabase.ts @@ -0,0 +1,103 @@ +/* + * 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 { resolvePackagePath } from '@backstage/backend-common'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import { + EntityResults, + Languages, + ProcessedEntity, +} from '@backstage/plugin-linguist-common'; + +export type RawDbEntityResultRow = { + id: string; + entity_ref: string; + languages: string; + processed_date: Date; +}; + +/** @public */ +export interface LinguistBackendStore { + insertEntityResults(entityLanguages: EntityResults): Promise; + getEntityResults(entityRef: string): Promise; + getProcessedEntities(): Promise; +} + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-linguist-backend', + 'migrations', +); + +/** @public */ +export class LinguistBackendDatabase implements LinguistBackendStore { + static async create(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + }); + return new LinguistBackendDatabase(knex); + } + + constructor(private readonly db: Knex) {} + + async insertEntityResults(entityLanguages: EntityResults): Promise { + const entityLanguageId = uuid(); + const entityRef = entityLanguages.entityRef; + + const [result] = await this.db('entity_result') + .insert({ + id: entityLanguageId, + entity_ref: entityRef, + languages: JSON.stringify(entityLanguages.results), + processed_date: new Date(entityLanguages.results.processedDate), + }) + .onConflict('entity_ref') + .merge(['languages', 'processed_date']) + .returning('id'); + + return result.id; + } + + async getEntityResults(entityRef: string): Promise { + const entityResults = await this.db('entity_result') + .where({ entity_ref: entityRef }) + .first(); + + if (!entityResults) { + const emptyResults: Languages = { + languageCount: 0, + totalBytes: 0, + processedDate: 'undefined', + breakdown: [], + }; + return emptyResults; + } + + try { + return JSON.parse(entityResults.languages); + } catch (error) { + throw new Error(`Failed to parse languages for '${entityRef}', ${error}`); + } + } + + async getProcessedEntities(): Promise { + const entityResults = await this.db( + 'entity_result', + ).select('entity_ref', 'processed_date'); + + return entityResults; + } +} diff --git a/plugins/linguist-backend/src/db/index.ts b/plugins/linguist-backend/src/db/index.ts new file mode 100644 index 0000000000..c8ec15da68 --- /dev/null +++ b/plugins/linguist-backend/src/db/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { LinguistBackendDatabase } from './LinguistBackendDatabase'; +export type { LinguistBackendStore } from './LinguistBackendDatabase'; diff --git a/plugins/linguist-backend/src/index.ts b/plugins/linguist-backend/src/index.ts new file mode 100644 index 0000000000..da1e0ffb00 --- /dev/null +++ b/plugins/linguist-backend/src/index.ts @@ -0,0 +1,20 @@ +/* + * 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 * from './service/router'; +export { LinguistBackendApi } from './api'; +export { LinguistBackendDatabase } from './db'; +export type { LinguistBackendStore } from './db'; diff --git a/plugins/linguist-backend/src/run.ts b/plugins/linguist-backend/src/run.ts new file mode 100644 index 0000000000..d945aa13f0 --- /dev/null +++ b/plugins/linguist-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/linguist-backend/src/service/router.test.ts b/plugins/linguist-backend/src/service/router.test.ts new file mode 100644 index 0000000000..0e20987a43 --- /dev/null +++ b/plugins/linguist-backend/src/service/router.test.ts @@ -0,0 +1,83 @@ +/* + * 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 { + DatabaseManager, + getVoidLogger, + PluginDatabaseManager, + PluginEndpointDiscovery, + UrlReaders, +} from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { ConfigReader } from '@backstage/config'; +import { createRouter } from './router'; +import { LinguistBackendApi } from '../api'; + +function createDatabase(): PluginDatabaseManager { + return DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('code-coverage'); +} + +const testDiscovery: jest.Mocked = { + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7007/api/code-coverage'), + getExternalBaseUrl: jest.fn(), +}; + +const mockUrlReader = UrlReaders.default({ + logger: getVoidLogger(), + config: new ConfigReader({}), +}); + +describe('createRouter', () => { + let linguistBackendApi: jest.Mocked; + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + linguistBackendApi, + config: new ConfigReader({}), + discovery: testDiscovery, + database: createDatabase(), + reader: mockUrlReader, + logger: getVoidLogger(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/linguist-backend/src/service/router.ts b/plugins/linguist-backend/src/service/router.ts new file mode 100644 index 0000000000..091b7f408d --- /dev/null +++ b/plugins/linguist-backend/src/service/router.ts @@ -0,0 +1,108 @@ +/* + * 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 { + errorHandler, + PluginDatabaseManager, + PluginEndpointDiscovery, + UrlReader, +} from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { LinguistBackendApi } from '../api'; +import { LinguistBackendDatabase } from '../db'; +import { + PluginTaskScheduler, + TaskScheduleDefinition, +} from '@backstage/backend-tasks'; + +/** @public */ +export interface RouterOptions { + linguistBackendApi?: LinguistBackendApi; + config: Config; + logger: Logger; + reader: UrlReader; + database: PluginDatabaseManager; + discovery: PluginEndpointDiscovery; + scheduler?: PluginTaskScheduler; + schedule?: TaskScheduleDefinition; +} + +/** @public */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { config, logger, reader, database, discovery, scheduler, schedule } = + options; + + const linguistBackendStore = await LinguistBackendDatabase.create( + await database.getClient(), + ); + + const linguistBackendApi = + options.linguistBackendApi || + new LinguistBackendApi( + config, + logger, + linguistBackendStore, + reader, + discovery, + ); + + if (scheduler && schedule) { + logger.info( + `Scheduling processing of entities with: ${JSON.stringify(schedule)}`, + ); + await scheduler.scheduleTask({ + id: 'linguist_process_entities', + frequency: schedule.frequency, + timeout: schedule.timeout, + initialDelay: schedule.initialDelay, + scope: schedule.scope, + fn: async () => { + await linguistBackendApi.processEntities(); + }, + }); + } + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + response.send({ status: 'ok' }); + }); + + /** + * /entity-languages?entity=component:default/my-component + */ + router.get('/entity-languages', async (req, res) => { + const { entityRef: entityRef } = req.query; + + if (!entityRef) { + throw new Error('No entityRef was provided'); + } + + const entityLanguages = await linguistBackendApi.getEntityLanguages( + entityRef as string, + ); + res.status(200).json(entityLanguages); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/linguist-backend/src/service/standaloneServer.ts b/plugins/linguist-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..5578c20e95 --- /dev/null +++ b/plugins/linguist-backend/src/service/standaloneServer.ts @@ -0,0 +1,76 @@ +/* + * 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 { + createServiceBuilder, + loadBackendConfig, + SingleHostDiscovery, + UrlReaders, + useHotMemoize, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; +import knexFactory from 'knex'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'linguist-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const db = useHotMemoize(module, () => { + const knex = knexFactory({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + + return knex; + }); + + logger.debug('Starting application server...'); + const router = await createRouter({ + database: { getClient: async () => db }, + config, + discovery: SingleHostDiscovery.fromConfig(config), + reader: UrlReaders.default({ logger, config }), + logger, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/linguist', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/linguist-backend/src/setupTests.ts b/plugins/linguist-backend/src/setupTests.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/linguist-backend/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. + */ + +export {}; diff --git a/plugins/linguist-common/.eslintrc.js b/plugins/linguist-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/linguist-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/linguist-common/README.md b/plugins/linguist-common/README.md new file mode 100644 index 0000000000..9408f7fcc5 --- /dev/null +++ b/plugins/linguist-common/README.md @@ -0,0 +1,3 @@ +# Linguist Common + +Common types, permissions, and constants for the Linguist plugin. diff --git a/plugins/linguist-common/api-report.md b/plugins/linguist-common/api-report.md new file mode 100644 index 0000000000..a9eceb3839 --- /dev/null +++ b/plugins/linguist-common/api-report.md @@ -0,0 +1,50 @@ +## API Report File for "@backstage/plugin-linguist-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Entity } from '@backstage/catalog-model'; + +// @public (undocumented) +export type EntitiesOverview = { + entityCount: number; + processedCount: number; + pendingCount: number; + staleCount: number; + filteredEntities: Entity[]; +}; + +// @public (undocumented) +export type EntityResults = { + entityRef: string; + results: Languages; +}; + +// @public (undocumented) +export type Language = { + name: string; + percentage: number; + bytes: number; + type: string; + color?: `#${string}`; +}; + +// @public (undocumented) +export type Languages = { + languageCount: number; + totalBytes: number; + processedDate: string; + breakdown: Language[]; +}; + +// @public (undocumented) +export const LINGUIST_ANNOTATION = 'backstage.io/linguist'; + +// @public (undocumented) +export type ProcessedEntity = { + entityRef: string; + processed_date: Date; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/linguist-common/package.json b/plugins/linguist-common/package.json new file mode 100644 index 0000000000..eddc114a67 --- /dev/null +++ b/plugins/linguist-common/package.json @@ -0,0 +1,35 @@ +{ + "name": "@backstage/plugin-linguist-common", + "description": "Common functionalities for the linguist plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/linguist-common/src/constants.ts b/plugins/linguist-common/src/constants.ts new file mode 100644 index 0000000000..55b3d21d02 --- /dev/null +++ b/plugins/linguist-common/src/constants.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +/** @public */ +export const LINGUIST_ANNOTATION = 'backstage.io/linguist'; diff --git a/plugins/linguist-common/src/index.ts b/plugins/linguist-common/src/index.ts new file mode 100644 index 0000000000..88fe08c714 --- /dev/null +++ b/plugins/linguist-common/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './types'; +export * from './constants'; diff --git a/plugins/linguist-common/src/setupTests.ts b/plugins/linguist-common/src/setupTests.ts new file mode 100644 index 0000000000..8b9b6bd586 --- /dev/null +++ b/plugins/linguist-common/src/setupTests.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 {}; diff --git a/plugins/linguist-common/src/types.ts b/plugins/linguist-common/src/types.ts new file mode 100644 index 0000000000..01222492b6 --- /dev/null +++ b/plugins/linguist-common/src/types.ts @@ -0,0 +1,54 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; + +/** @public */ +export type EntityResults = { + entityRef: string; + results: Languages; +}; + +/** @public */ +export type Languages = { + languageCount: number; + totalBytes: number; + processedDate: string; + breakdown: Language[]; +}; + +/** @public */ +export type Language = { + name: string; + percentage: number; + bytes: number; + type: string; + color?: `#${string}`; +}; + +/** @public */ +export type ProcessedEntity = { + entityRef: string; + processed_date: Date; +}; + +/** @public */ +export type EntitiesOverview = { + entityCount: number; + processedCount: number; + pendingCount: number; + staleCount: number; + filteredEntities: Entity[]; +}; diff --git a/plugins/linguist/.eslintrc.js b/plugins/linguist/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/linguist/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/linguist/README.md b/plugins/linguist/README.md new file mode 100644 index 0000000000..d5ca2199dd --- /dev/null +++ b/plugins/linguist/README.md @@ -0,0 +1,88 @@ +# Linguist + +Welcome to the Linguist plugin! + +## Features + +The Linguist plugin consists of a card that will give you the breakdown of the languages used by the configured Entity in the Catalog. + +When there is no Linguist data yet the card will be empty: + +![Example of empty Linguist card](./docs/linguist-no-data.png) + +Clicking on the refresh icon will generate the language breakdown: + +![Example of Linguist card with data](./docs/linguist-with-data.png) + +Here's an example where the permissions are hiding the refresh button: + +![Example of Linguist card without the refresh button](./docs/linguist-no-refresh.png) + +## Setup + +The following sections will help you get the Linguist plugin setup and running. + +### Backend + +You need to setup the [Linguist backend plugin](../linguist-backend/README.md) before you move forward with any of the following steps if you haven't already. + +### Entity Annotation + +To be able to use the Linguist plugin you need to add the following annotation to any entities you want to use it with: + +```yaml +backstage.io/linguist: https://url.to/your/srouce/code +``` + +Linguist uses the `UrlReader` to pull down the files before it scans them to determine the languages, this means you can pull files from all the supported providers - GitHub, GitLab, Bitbucket, Azure, Google GCS, AWS S3, etc. + +Here's what that will look like in action using the Backstage repo as an example: + +```yaml +# Example catalog-info.yaml entity definition file +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + backstage.io/linguist: https://github.com/backstage/backstage +spec: + type: service + # ... +``` + +### Frontend + +To setup the Linguist Card frontend you'll need to do the following steps: + +1. First we need to add the `@backstage/plugin-linguist` package to your frontend app: + + ```sh + # From your Backstage root directory + yarn add --cwd packages/app @backstage/plugin-linguist + ``` + +2. Second we need to add the `EntityLinguistCard` extension to the entity page in your app: + + ```tsx + // In packages/app/src/components/catalog/EntityPage.tsx + import { isLinguistAvailable, EntityLinguistCard } from '@backstage/plugin-linguist'; + + // For example in the Overview section + const overviewContent = ( + + // ... + + + + + + + + // ... + + ``` + +**Notes:** + +- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation diff --git a/plugins/linguist/api-report.md b/plugins/linguist/api-report.md new file mode 100644 index 0000000000..469b3b93c0 --- /dev/null +++ b/plugins/linguist/api-report.md @@ -0,0 +1,21 @@ +## API Report File for "@backstage/plugin-linguist" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; + +// @public (undocumented) +export const EntityLinguistCard: () => JSX.Element; + +// @public (undocumented) +export const isLinguistAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const linguistPlugin: BackstagePlugin<{}, {}, {}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/linguist/dev/index.tsx b/plugins/linguist/dev/index.tsx new file mode 100644 index 0000000000..bb6fb64b52 --- /dev/null +++ b/plugins/linguist/dev/index.tsx @@ -0,0 +1,27 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { linguistPlugin, EntityLinguistCard } from '../src/plugin'; + +createDevApp() + .registerPlugin(linguistPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/linguist', + }) + .render(); diff --git a/plugins/linguist/docs/linguist-no-data.png b/plugins/linguist/docs/linguist-no-data.png new file mode 100644 index 0000000000000000000000000000000000000000..208b8df5d54f73c71a3ae070587c46d0e853a788 GIT binary patch literal 12616 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 Mw4zk5guc)J0^%y>`2YX_ literal 0 HcmV?d00001 diff --git a/plugins/linguist/docs/linguist-no-refresh.png b/plugins/linguist/docs/linguist-no-refresh.png new file mode 100644 index 0000000000000000000000000000000000000000..a3b4e4fde7fa9a38fb1f4e810a14e800c31b0837 GIT binary patch literal 15731 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 literal 0 HcmV?d00001 diff --git a/plugins/linguist/docs/linguist-with-data.png b/plugins/linguist/docs/linguist-with-data.png new file mode 100644 index 0000000000000000000000000000000000000000..e673f994fab4d1db32b0a1cfb1c06bad657efee3 GIT binary patch literal 22018 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 + id: 'plugin.linguist.service', +}); + +export interface LinguistApi { + getLanguages(entityRef: string): Promise; +} diff --git a/plugins/linguist/src/api/LinguistClient.ts b/plugins/linguist/src/api/LinguistClient.ts new file mode 100644 index 0000000000..3e327ff21e --- /dev/null +++ b/plugins/linguist/src/api/LinguistClient.ts @@ -0,0 +1,59 @@ +/* + * 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 { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { Languages } from '@backstage/plugin-linguist-common'; +import { LinguistApi } from './LinguistApi'; + +export class LinguistClient implements LinguistApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + public constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + } + + public async getLanguages(entityRef: string): Promise { + const queryString = new URLSearchParams(); + queryString.append('entityRef', entityRef); + + const urlSegment = `entity-languages?${queryString}`; + + const items = await this.get(urlSegment); + return items; + } + + private async get(path: string): Promise { + const baseUrl = `${await this.discoveryApi.getBaseUrl('linguist')}/`; + const url = new URL(path, baseUrl); + + const { token } = await this.identityApi.getCredentials(); + const response = await fetch(url.toString(), { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json() as Promise; + } +} diff --git a/plugins/linguist/src/api/index.ts b/plugins/linguist/src/api/index.ts new file mode 100644 index 0000000000..ca60b60d53 --- /dev/null +++ b/plugins/linguist/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './LinguistApi'; +export * from './LinguistClient'; diff --git a/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx new file mode 100644 index 0000000000..6014487d30 --- /dev/null +++ b/plugins/linguist/src/components/LinguistCard/LinguistCard.tsx @@ -0,0 +1,153 @@ +/* + * 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 { + Box, + Chip, + Tooltip, + Typography, + makeStyles, + Grid, + useTheme, +} from '@material-ui/core'; +import { InfoCard, Progress } from '@backstage/core-components'; +import Alert from '@material-ui/lab/Alert'; +import { DateTime } from 'luxon'; +import React from 'react'; +import slugify from 'slugify'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useLanguages } from '../../hooks'; + +const useStyles = makeStyles(theme => ({ + infoCard: { + marginBottom: theme.spacing(3), + }, + barContainer: { + height: theme.spacing(2), + marginBottom: theme.spacing(3), + borderRadius: '4px', + backgroundColor: 'transparent', + overflow: 'hidden', + }, + bar: { + height: '100%', + position: 'relative', + }, + languageDot: { + width: '10px', + height: '10px', + borderRadius: '50%', + marginRight: theme.spacing(1), + display: 'inline-block', + }, + label: { + color: 'inherit', + }, +})); + +export const LinguistCard = () => { + const classes = useStyles(); + const theme = useTheme(); + const { entity } = useEntity(); + const { items, loading, error } = useLanguages(entity); + let barWidth = 0; + + if (loading) { + return ; + } else if (error) { + return {error.message}; + } + + if (items && items.languageCount === 0 && items.totalBytes === 0) { + return ( + + + + + There is currently no language data for this entity. + + + + + ); + } + + const breakdown = items?.breakdown.sort((a, b) => + a.percentage < b.percentage ? 1 : -1, + ); + const processedDate = items?.processedDate; + + return breakdown && processedDate ? ( + + + {breakdown.map((language, index: number) => { + barWidth = barWidth + language.percentage; + return ( + + + + ); + })} + + + + {breakdown.map(languages => ( + + + {languages.name} - {languages.percentage}% + + } + variant="outlined" + key={slugify(languages.name, { lower: true })} + /> + ))} + + + + ) : ( + <> + ); +}; diff --git a/plugins/linguist/src/components/LinguistCard/index.ts b/plugins/linguist/src/components/LinguistCard/index.ts new file mode 100644 index 0000000000..aefae143fd --- /dev/null +++ b/plugins/linguist/src/components/LinguistCard/index.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. + */ + +export { LinguistCard } from './LinguistCard'; diff --git a/plugins/linguist/src/hooks/index.ts b/plugins/linguist/src/hooks/index.ts new file mode 100644 index 0000000000..6f8913cf35 --- /dev/null +++ b/plugins/linguist/src/hooks/index.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. + */ + +export * from './useLanguages'; diff --git a/plugins/linguist/src/hooks/useLanguages.ts b/plugins/linguist/src/hooks/useLanguages.ts new file mode 100644 index 0000000000..3e99243e94 --- /dev/null +++ b/plugins/linguist/src/hooks/useLanguages.ts @@ -0,0 +1,41 @@ +/* + * 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 { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { linguistApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; + +import { Languages } from '@backstage/plugin-linguist-common'; + +export function useLanguages(entity: Entity): { + items?: Languages; + loading: boolean; + error?: Error; +} { + const entityRef = stringifyEntityRef(entity); + + const api = useApi(linguistApiRef); + const { value, loading, error } = useAsync(() => { + return api.getLanguages(entityRef); + }, [api, entityRef]); + + return { + items: value, + loading, + error, + }; +} diff --git a/plugins/linguist/src/index.ts b/plugins/linguist/src/index.ts new file mode 100644 index 0000000000..ce9cd98cbd --- /dev/null +++ b/plugins/linguist/src/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { + linguistPlugin, + isLinguistAvailable, + EntityLinguistCard, +} from './plugin'; diff --git a/plugins/linguist/src/plugin.test.ts b/plugins/linguist/src/plugin.test.ts new file mode 100644 index 0000000000..d2a1016a22 --- /dev/null +++ b/plugins/linguist/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 { linguistPlugin } from './plugin'; + +describe('linguist', () => { + it('should export plugin', () => { + expect(linguistPlugin).toBeDefined(); + }); +}); diff --git a/plugins/linguist/src/plugin.ts b/plugins/linguist/src/plugin.ts new file mode 100644 index 0000000000..917a13ca9a --- /dev/null +++ b/plugins/linguist/src/plugin.ts @@ -0,0 +1,53 @@ +/* + * 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 { + createApiFactory, + createComponentExtension, + createPlugin, + discoveryApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { linguistApiRef, LinguistClient } from './api'; +import { LINGUIST_ANNOTATION } from '@backstage/plugin-linguist-common'; +import { Entity } from '@backstage/catalog-model'; + +/** @public */ +export const isLinguistAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[LINGUIST_ANNOTATION]); + +/** @public */ +export const linguistPlugin = createPlugin({ + id: 'linguist', + apis: [ + createApiFactory({ + api: linguistApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new LinguistClient({ discoveryApi, identityApi }), + }), + ], +}); + +/** @public */ +export const EntityLinguistCard = linguistPlugin.provide( + createComponentExtension({ + name: 'EntityLinguistCard', + component: { + lazy: () => import('./components/LinguistCard').then(m => m.LinguistCard), + }, + }), +); diff --git a/plugins/linguist/src/setupTests.ts b/plugins/linguist/src/setupTests.ts new file mode 100644 index 0000000000..2b5b615b19 --- /dev/null +++ b/plugins/linguist/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * 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/yarn.lock b/yarn.lock index 345c28a8b5..9697e098ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6895,6 +6895,78 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-linguist-backend@workspace:^, @backstage/plugin-linguist-backend@workspace:plugins/linguist-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-linguist-backend@workspace:plugins/linguist-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-tasks": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-linguist-common": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.8 + express: ^4.18.1 + express-promise-router: ^4.1.0 + fs-extra: ^10.0.0 + knex: ^2.0.0 + linguist-js: ^2.5.3 + luxon: ^2.0.2 + msw: ^0.49.0 + node-fetch: ^2.6.7 + supertest: ^6.2.4 + uuid: ^8.3.2 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-linguist-common@workspace:^, @backstage/plugin-linguist-common@workspace:plugins/linguist-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-linguist-common@workspace:plugins/linguist-common" + dependencies: + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + languageName: unknown + linkType: soft + +"@backstage/plugin-linguist@workspace:^, @backstage/plugin-linguist@workspace:plugins/linguist": + version: 0.0.0-use.local + resolution: "@backstage/plugin-linguist@workspace:plugins/linguist" + dependencies: + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-linguist-common": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.57 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + "@types/node": "*" + cross-fetch: ^3.1.5 + luxon: ^2.0.2 + msw: ^0.49.0 + react-use: ^17.2.4 + slugify: ^1.6.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-microsoft-calendar@workspace:^, @backstage/plugin-microsoft-calendar@workspace:plugins/microsoft-calendar": version: 0.0.0-use.local resolution: "@backstage/plugin-microsoft-calendar@workspace:plugins/microsoft-calendar" @@ -11244,7 +11316,7 @@ __metadata: languageName: node linkType: hard -"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4": +"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4, @material-ui/core@npm:^4.9.13": version: 4.12.4 resolution: "@material-ui/core@npm:4.12.4" dependencies: @@ -17580,10 +17652,10 @@ __metadata: languageName: node linkType: hard -"binary-extensions@npm:^2.0.0": - version: 2.0.0 - resolution: "binary-extensions@npm:2.0.0" - checksum: 554f65d3378cf71c3185c17dec3ca58334b8ff6ae242db3107284765ce33b2af19efd20c11faec41907a40534929e34b3a98e7d391c61e4211b45732dccb1115 +"binary-extensions@npm:^2.0.0, binary-extensions@npm:^2.2.0": + version: 2.2.0 + resolution: "binary-extensions@npm:2.2.0" + checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8 languageName: node linkType: hard @@ -18943,7 +19015,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:*, commander@npm:^9.1.0, commander@npm:^9.4.1": +"commander@npm:*, commander@npm:^9.1.0, commander@npm:^9.4.1, commander@npm:^9.5.0": version: 9.5.0 resolution: "commander@npm:9.5.0" checksum: c7a3e27aa59e913b54a1bafd366b88650bc41d6651f0cbe258d4ff09d43d6a7394232a4dadd0bf518b3e696fdf595db1028a0d82c785b88bd61f8a440cecfade @@ -22227,6 +22299,8 @@ __metadata: "@backstage/plugin-kafka": "workspace:^" "@backstage/plugin-kubernetes": "workspace:^" "@backstage/plugin-lighthouse": "workspace:^" + "@backstage/plugin-linguist": "workspace:^" + "@backstage/plugin-linguist-common": "workspace:^" "@backstage/plugin-microsoft-calendar": "workspace:^" "@backstage/plugin-newrelic": "workspace:^" "@backstage/plugin-newrelic-dashboard": "workspace:^" @@ -22326,6 +22400,7 @@ __metadata: "@backstage/plugin-kafka-backend": "workspace:^" "@backstage/plugin-kubernetes-backend": "workspace:^" "@backstage/plugin-lighthouse-backend": "workspace:^" + "@backstage/plugin-linguist-backend": "workspace:^" "@backstage/plugin-permission-backend": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" @@ -24806,10 +24881,10 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.1.4, ignore@npm:^5.2.0": - version: 5.2.0 - resolution: "ignore@npm:5.2.0" - checksum: 6b1f926792d614f64c6c83da3a1f9c83f6196c2839aa41e1e32dd7b8d174cef2e329d75caabb62cb61ce9dc432f75e67d07d122a037312db7caa73166a1bdb77 +"ignore@npm:^5.1.4, ignore@npm:^5.2.0, ignore@npm:^5.2.4": + version: 5.2.4 + resolution: "ignore@npm:5.2.4" + checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef languageName: node linkType: hard @@ -25826,7 +25901,7 @@ __metadata: languageName: node linkType: hard -"isbinaryfile@npm:^4.0.10, isbinaryfile@npm:^4.0.8": +"isbinaryfile@npm:^4.0.10, isbinaryfile@npm:^4.0.8, isbinaryfile@npm:^4.0.8 <5": version: 4.0.10 resolution: "isbinaryfile@npm:4.0.10" checksum: a6b28db7e23ac7a77d3707567cac81356ea18bd602a4f21f424f862a31d0e7ab4f250759c98a559ece35ffe4d99f0d339f1ab884ffa9795172f632ab8f88e686 @@ -27699,6 +27774,25 @@ __metadata: languageName: node linkType: hard +"linguist-js@npm:^2.5.3": + version: 2.5.4 + resolution: "linguist-js@npm:2.5.4" + dependencies: + binary-extensions: ^2.2.0 + commander: ^9.5.0 + common-path-prefix: ^3.0.0 + cross-fetch: ^3.1.5 + ignore: ^5.2.4 + isbinaryfile: ^4.0.8 <5 + js-yaml: ^4.1.0 + node-cache: ^5.1.2 + bin: + linguist: bin/index.js + linguist-js: bin/index.js + checksum: 92ebfc45f0f17a056153068f7e939a3e6755307d3696673597e824011d83b698063241aa7d082a7f9059416efce85a49056f0a31b3158c25f79e273e2f51e309 + languageName: node + linkType: hard + "linkify-it@npm:^3.0.1": version: 3.0.2 resolution: "linkify-it@npm:3.0.2" @@ -28244,6 +28338,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^2.0.2": + version: 2.5.2 + resolution: "luxon@npm:2.5.2" + checksum: d8b671ffd2ff0b438af862ac11082a81b3aedd9f8b6a4ca636f944224f8dd381125cd4d274ca0a8aedcaf2cfca0fc2ca96fe4cc1b16a28b7af701afb95c0bff8 + languageName: node + linkType: hard + "luxon@npm:^3.0.0, luxon@npm:^3.2.1": version: 3.2.1 resolution: "luxon@npm:3.2.1" @@ -35075,6 +35176,13 @@ __metadata: languageName: node linkType: hard +"slugify@npm:^1.6.4": + version: 1.6.5 + resolution: "slugify@npm:1.6.5" + checksum: a955a1b600201030f4c1daa9bb74a17d4402a0693fc40978bbd17e44e64fd72dad3bac4037422aa8aed55b5170edd57f3f4cd8f59ba331f5cf0f10f1a7795609 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0"