diff --git a/.changeset/chatty-forks-bathe.md b/.changeset/chatty-forks-bathe.md new file mode 100644 index 0000000000..e6d3816cc6 --- /dev/null +++ b/.changeset/chatty-forks-bathe.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-adr': minor +'@backstage/plugin-adr-backend': minor +'@backstage/plugin-adr-common': minor +--- + +Implement ADR plugin diff --git a/microsite/data/plugins/adr.yaml b/microsite/data/plugins/adr.yaml new file mode 100644 index 0000000000..f180c80e36 --- /dev/null +++ b/microsite/data/plugins/adr.yaml @@ -0,0 +1,9 @@ +--- +title: Architecture Decision Records +author: Phil Kuang +authorUrl: https://github.com/kuangp +category: Discovery +description: Browse your project's ADRs. +documentation: https://github.com/backstage/backstage/tree/master/plugins/adr +iconUrl: img/adr-logo.png +npmPackageName: '@backstage/plugin-adr' diff --git a/microsite/static/img/adr-logo.png b/microsite/static/img/adr-logo.png new file mode 100644 index 0000000000..a71c54074d Binary files /dev/null and b/microsite/static/img/adr-logo.png differ diff --git a/plugins/adr-backend/.eslintrc.js b/plugins/adr-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/adr-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/adr-backend/README.md b/plugins/adr-backend/README.md new file mode 100644 index 0000000000..79b5fe13ed --- /dev/null +++ b/plugins/adr-backend/README.md @@ -0,0 +1,47 @@ +# ADR Backend + +This ADR backend plugin is primarily responsible for the following: + +- Provides a `DefaultAdrCollatorFactory`, which can be used in the search backend to index ADR documents associated with entities to your Backstage Search. + +## Indexing ADR documents for search + +Before you are able to start indexing ADR documents to search, you need to go through the [search getting started guide](https://backstage.io/docs/features/search/getting-started). + +When you have your `packages/backend/src/plugins/search.ts` file ready to make modifications, install this plugin and add the following code snippet to add the `DefaultAdrCollatorFactory`. Also make sure to set up the frontend [ADR plugin](../adr/README.md) so search results can be routed correctly. + +```bash +# From your Backstage root directory +yarn --cwd packages/backend add @backstage/plugin-adr-backend +``` + +```ts +import { DefaultAdrCollatorFactory } from '@backstage/plugin-adr-backend'; + +... + +indexBuilder.addCollator({ + schedule, + factory: DefaultAdrCollatorFactory.fromConfig({ + cache: env.cache, + config: env.config, + discovery: env.discovery, + logger: env.logger, + reader: env.reader, + tokenManager: env.tokenManager, + }), +}); +``` + +### Parsing custom ADR document formats + +By default, the `DefaultAdrCollatorFactory` will parse and index documents that follow the [MADR](https://adr.github.io/madr/) standard file name and template format. If you use a different ADR format and file name convention, you can configure `DefaultAdrCollatorFactory` with custom `adrFilePathFilterFn` and `parser` options (see type definitions for details): + +```ts +DefaultAdrCollatorFactory.fromConfig({ + ... + parser: myCustomAdrParser, + adrFilePathFilterFn: myCustomAdrFilePathFilter, + ... +}) +``` diff --git a/plugins/adr-backend/api-report.md b/plugins/adr-backend/api-report.md new file mode 100644 index 0000000000..5a671e9c8f --- /dev/null +++ b/plugins/adr-backend/api-report.md @@ -0,0 +1,66 @@ +## API Report File for "@backstage/plugin-adr-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { AdrDocument } from '@backstage/plugin-adr-common'; +import { AdrFilePathFilterFn } from '@backstage/plugin-adr-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; +import { Entity } from '@backstage/catalog-model'; +import { Logger } from 'winston'; +import { PluginCacheManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Readable } from 'stream'; +import { TokenManager } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; + +// @public +export type AdrCollatorFactoryOptions = { + adrFilePathFilterFn?: AdrFilePathFilterFn; + cache: PluginCacheManager; + config: Config; + catalogClient?: CatalogApi; + discovery: PluginEndpointDiscovery; + logger: Logger; + parser?: AdrParser; + reader: UrlReader; + tokenManager: TokenManager; +}; + +// @public +export type AdrParser = (ctx: AdrParserContext) => Promise; + +// @public +export type AdrParserContext = { + entity: Entity; + content: string; + path: string; +}; + +// @public +export const createMadrParser: (options?: MadrParserOptions) => AdrParser; + +// @public +export class DefaultAdrCollatorFactory implements DocumentCollatorFactory { + // (undocumented) + execute(): AsyncGenerator; + // (undocumented) + static fromConfig( + options: AdrCollatorFactoryOptions, + ): DefaultAdrCollatorFactory; + // (undocumented) + getCollator(): Promise; + // (undocumented) + readonly type: string; +} + +// @public +export type MadrParserOptions = { + locationTemplate?: string; + dateFormat?: string; +}; +``` diff --git a/plugins/adr-backend/package.json b/plugins/adr-backend/package.json new file mode 100644 index 0000000000..e26d27ccee --- /dev/null +++ b/plugins/adr-backend/package.json @@ -0,0 +1,56 @@ +{ + "name": "@backstage/plugin-adr-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/adr-backend" + }, + "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": "^0.13.3-next.0", + "@backstage/catalog-client": "^1.0.1", + "@backstage/catalog-model": "^1.0.1", + "@backstage/config": "^1.0.0", + "@backstage/errors": "^1.0.0", + "@backstage/integration": "^1.2.0-next.0", + "@backstage/plugin-adr-common": "^0.0.0", + "@backstage/plugin-search-common": "^0.3.3", + "luxon": "^2.0.2", + "marked": "^4.0.14", + "winston": "^3.2.1", + "node-fetch": "^2.6.5", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.17.1-next.0", + "@types/marked": "^4.0.0", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3", + "msw": "^0.35.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/adr-backend/src/index.ts b/plugins/adr-backend/src/index.ts new file mode 100644 index 0000000000..86e07943df --- /dev/null +++ b/plugins/adr-backend/src/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * ADR backend plugin + * + * @packageDocumentation + */ + +export * from './search'; diff --git a/plugins/adr-backend/src/search/DefaultAdrCollatorFactory.ts b/plugins/adr-backend/src/search/DefaultAdrCollatorFactory.ts new file mode 100644 index 0000000000..e0c90af767 --- /dev/null +++ b/plugins/adr-backend/src/search/DefaultAdrCollatorFactory.ts @@ -0,0 +1,212 @@ +/* + * 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 { Readable } from 'stream'; +import { Logger } from 'winston'; +import { + CacheClient, + PluginCacheManager, + PluginEndpointDiscovery, + TokenManager, + UrlReader, +} from '@backstage/backend-common'; +import { + CatalogApi, + CatalogClient, + CATALOG_FILTER_EXISTS, +} from '@backstage/catalog-client'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { NotModifiedError } from '@backstage/errors'; +import { + ScmIntegrationRegistry, + ScmIntegrations, +} from '@backstage/integration'; +import { + AdrDocument, + AdrFilePathFilterFn, + ANNOTATION_ADR_LOCATION, + getAdrLocationUrl, + madrFilePathFilter, +} from '@backstage/plugin-adr-common'; +import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; + +import { createMadrParser } from './createMadrParser'; +import { AdrParser } from './types'; + +/** + * Options to configure the AdrCollatorFactory + * @public + */ +export type AdrCollatorFactoryOptions = { + /** + * Function used to filter ADR file paths. + * Defaults to filtering paths following MADR filename format. + */ + adrFilePathFilterFn?: AdrFilePathFilterFn; + /** + * Plugin cache manager + */ + cache: PluginCacheManager; + /** + * App Config + */ + config: Config; + /** + * Catalog API client. Defaults to CatalogClient. + */ + catalogClient?: CatalogApi; + /** + * Plugin Endpoint Discovery client + */ + discovery: PluginEndpointDiscovery; + /** + * Logger + */ + logger: Logger; + /** + * ADR content parser. Defaults to built in MADR parser. + */ + parser?: AdrParser; + /** + * URL Reader + */ + reader: UrlReader; + /** + * Token Manager + */ + tokenManager: TokenManager; +}; + +/** + * Default collator to index ADR documents for Backstage search. + * @public + */ +export class DefaultAdrCollatorFactory implements DocumentCollatorFactory { + public readonly type: string = 'adr'; + private readonly adrFilePathFilterFn: AdrFilePathFilterFn; + private readonly cacheClient: CacheClient; + private readonly catalogClient: CatalogApi; + private readonly logger: Logger; + private readonly parser: AdrParser; + private readonly reader: UrlReader; + private readonly scmIntegrations: ScmIntegrationRegistry; + private readonly tokenManager: TokenManager; + + private constructor(options: AdrCollatorFactoryOptions) { + this.adrFilePathFilterFn = + options.adrFilePathFilterFn ?? madrFilePathFilter; + this.cacheClient = options.cache.getClient(); + this.catalogClient = + options.catalogClient ?? + new CatalogClient({ discoveryApi: options.discovery }); + this.logger = options.logger; + this.parser = options.parser ?? createMadrParser(); + this.reader = options.reader; + this.scmIntegrations = ScmIntegrations.fromConfig(options.config); + this.tokenManager = options.tokenManager; + } + + static fromConfig(options: AdrCollatorFactoryOptions) { + return new DefaultAdrCollatorFactory(options); + } + + async getCollator() { + return Readable.from(this.execute()); + } + + async *execute(): AsyncGenerator { + const { token } = await this.tokenManager.getToken(); + const entities = ( + await this.catalogClient.getEntities( + { + filter: { + [`metadata.annotations.${ANNOTATION_ADR_LOCATION}`]: + CATALOG_FILTER_EXISTS, + }, + fields: [ + 'kind', + 'metadata.annotations', + 'metadata.name', + 'metadata.namespace', + ], + }, + { token }, + ) + ).items; + + for (const ent of entities) { + let adrsUrl: string; + try { + adrsUrl = getAdrLocationUrl(ent, this.scmIntegrations); + } catch (e: any) { + this.logger.error( + `Unable to get ADR location URL for ${stringifyEntityRef(ent)}: ${ + e.message + }`, + ); + continue; + } + + const cacheItem = (await this.cacheClient.get(adrsUrl)) as { + adrFiles: { + path: string; + content: string; + }[]; + etag: string; + }; + + let adrFiles = cacheItem?.adrFiles; + try { + const tree = await this.reader.readTree(adrsUrl, { + etag: cacheItem?.etag, + filter: filePath => this.adrFilePathFilterFn(filePath), + }); + + adrFiles = await Promise.all( + ( + await tree.files() + ).map(async f => ({ + path: f.path, + content: (await f.content()).toString('utf8'), + })), + ); + + await this.cacheClient.set(adrsUrl, { adrFiles, etag: tree.etag }); + } catch (error: any) { + // Ignore error if we're able to use cached response + if (!cacheItem || error.name !== NotModifiedError.name) { + throw error; + } + } + + for (const { content, path } of adrFiles) { + try { + const adrDoc = await this.parser({ + entity: ent, + path, + content, + }); + yield adrDoc; + } catch (e: any) { + this.logger.error(`Unable to parse ADR ${path}: ${e.message}`); + } + } + + this.logger.info(`Indexed ${adrFiles.length} ADRs from ${adrsUrl}`); + } + } +} diff --git a/plugins/adr-backend/src/search/createMadrParser.ts b/plugins/adr-backend/src/search/createMadrParser.ts new file mode 100644 index 0000000000..9efd49754a --- /dev/null +++ b/plugins/adr-backend/src/search/createMadrParser.ts @@ -0,0 +1,112 @@ +/* + * 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 { DateTime } from 'luxon'; +import { marked } from 'marked'; +import { MADR_DATE_FORMAT } from '@backstage/plugin-adr-common'; + +import { AdrParser } from './types'; + +const applyArgsToFormat = ( + format: string, + args: Record, +): string => { + let formatted = format; + for (const [key, value] of Object.entries(args)) { + formatted = formatted.replace(`:${key}`, value); + } + return formatted.toLowerCase(); +}; + +const DEFAULT_LOCATION_TEMPLATE = + '/catalog/:namespace/:kind/:name/adrs?record=:record'; + +/** + * + * Options for the default MADR content parser + * @public + */ +export type MadrParserOptions = { + /** + * Location template for the route of the frontend plugin + * Defaults to '/catalog/:namespace/:kind/:name/adrs?record=:record' + */ + locationTemplate?: string; + /** + * luxon DateTime format string to parse ADR dates with. + * Defaults to 'yyyy-MM-dd' + */ + dateFormat?: string; +}; + +/** + * Default content parser for ADRs following the MADR template (https://adr.github.io/madr/) + * @public + */ +export const createMadrParser = ( + options: MadrParserOptions = {}, +): AdrParser => { + const locationTemplate = + options.locationTemplate ?? DEFAULT_LOCATION_TEMPLATE; + const dateFormat = options.dateFormat ?? MADR_DATE_FORMAT; + + return async ({ entity, content, path }) => { + const tokens = marked.lexer(content); + if (!tokens.length) { + throw new Error('ADR has no content'); + } + + // First h1 header should contain ADR title + const adrTitle = ( + tokens.find( + t => t.type === 'heading' && t.depth === 1, + ) as marked.Tokens.Heading + )?.text; + + // First list should contain status & date metadata (if defined) + const listTokens = ( + tokens.find(t => t.type === 'list') as marked.Tokens.List + )?.items; + const adrStatus = listTokens + .find(t => /^status:/i.test(t.text)) + ?.text.replace(/^status:/i, '') + .trim() + .toLocaleLowerCase('en-US'); + const adrDateTime = DateTime.fromFormat( + listTokens + .find(t => /^date:/i.test(t.text)) + ?.text.replace(/^date:/i, '') + .trim() ?? '', + dateFormat, + ); + const adrDate = adrDateTime.isValid + ? adrDateTime.toFormat(MADR_DATE_FORMAT) + : undefined; + + return { + title: adrTitle ?? path.replace(/\.md$/, ''), + text: content, + status: adrStatus, + date: adrDate, + location: applyArgsToFormat(locationTemplate, { + namespace: entity.metadata.namespace || 'default', + kind: entity.kind, + name: entity.metadata.name, + record: path, + }), + }; + }; +}; diff --git a/plugins/adr-backend/src/search/index.ts b/plugins/adr-backend/src/search/index.ts new file mode 100644 index 0000000000..33f14631ec --- /dev/null +++ b/plugins/adr-backend/src/search/index.ts @@ -0,0 +1,19 @@ +/* + * 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 './DefaultAdrCollatorFactory'; +export * from './createMadrParser'; +export * from './types'; diff --git a/plugins/adr-backend/src/search/types.ts b/plugins/adr-backend/src/search/types.ts new file mode 100644 index 0000000000..8ac47d5c8b --- /dev/null +++ b/plugins/adr-backend/src/search/types.ts @@ -0,0 +1,43 @@ +/* + * 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'; +import { AdrDocument } from '@backstage/plugin-adr-common'; + +/** + * Context passed to a AdrParser. + * @public + */ +export type AdrParserContext = { + /** + * The entity associated with the ADR. + */ + entity: Entity; + /** + * The ADR content string. + */ + content: string; + /** + * The ADR file path. + */ + path: string; +}; + +/** + * ADR parser function type. + * @public + */ +export type AdrParser = (ctx: AdrParserContext) => Promise; diff --git a/plugins/adr-common/.eslintrc.js b/plugins/adr-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/adr-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/adr-common/README.md b/plugins/adr-common/README.md new file mode 100644 index 0000000000..38b5b7433c --- /dev/null +++ b/plugins/adr-common/README.md @@ -0,0 +1,3 @@ +# ADR Common + +Common types and functionalities for the ADR plugin. diff --git a/plugins/adr-common/api-report.md b/plugins/adr-common/api-report.md new file mode 100644 index 0000000000..290d984181 --- /dev/null +++ b/plugins/adr-common/api-report.md @@ -0,0 +1,36 @@ +## API Report File for "@backstage/plugin-adr-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'; +import { IndexableDocument } from '@backstage/plugin-search-common'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +// @public +export interface AdrDocument extends IndexableDocument { + date?: string; + status?: string; +} + +// @public +export type AdrFilePathFilterFn = (path: string) => boolean; + +// @public +export const ANNOTATION_ADR_LOCATION = 'backstage.io/adr-location'; + +// @public +export const getAdrLocationUrl: ( + entity: Entity, + scmIntegration: ScmIntegrationRegistry, +) => string; + +// @public +export const isAdrAvailable: (entity: Entity) => boolean; + +// @public +export const MADR_DATE_FORMAT = 'yyyy-MM-dd'; + +// @public +export const madrFilePathFilter: AdrFilePathFilterFn; +``` diff --git a/plugins/adr-common/package.json b/plugins/adr-common/package.json new file mode 100644 index 0000000000..41e28423dc --- /dev/null +++ b/plugins/adr-common/package.json @@ -0,0 +1,43 @@ +{ + "name": "@backstage/plugin-adr-common", + "description": "Common functionalities for the adr plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/adr-common" + }, + "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": "^1.0.1", + "@backstage/integration": "^1.2.0-next.0", + "@backstage/plugin-search-common": "^0.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.17.1-next.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/adr-common/src/index.ts b/plugins/adr-common/src/index.ts new file mode 100644 index 0000000000..94b6b788c8 --- /dev/null +++ b/plugins/adr-common/src/index.ts @@ -0,0 +1,95 @@ +/* + * 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. + */ +/** + * Common types and functionalities for the ADR plugin. + * @packageDocumentation + */ +import { Entity, getEntitySourceLocation } from '@backstage/catalog-model'; +import { IndexableDocument } from '@backstage/plugin-search-common'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +/** + * ADR plugin annotation. + * @public + */ +export const ANNOTATION_ADR_LOCATION = 'backstage.io/adr-location'; + +/** + * Standard luxon DateTime format string for MADR dates. + * @public + */ +export const MADR_DATE_FORMAT = 'yyyy-MM-dd'; + +/** + * Utility function to get the value of an entity ADR annotation. + * @public + */ +const getAdrLocationDir = (entity: Entity) => + entity.metadata.annotations?.[ANNOTATION_ADR_LOCATION]?.trim(); + +/** + * Utility function to determine if the given entity has ADRs. + * @public + */ +export const isAdrAvailable = (entity: Entity) => + Boolean(getAdrLocationDir(entity)); + +/** + * Utility function to extract the ADR location URL from an entity based off + * its ADR annotation and relative to the entity source location. + * @public + */ +export const getAdrLocationUrl = ( + entity: Entity, + scmIntegration: ScmIntegrationRegistry, +) => { + if (!isAdrAvailable(entity)) { + throw new Error(`Missing ADR annotation: ${ANNOTATION_ADR_LOCATION}`); + } + + return scmIntegration.resolveUrl({ + url: getAdrLocationDir(entity)!, + base: getEntitySourceLocation(entity).target, + }); +}; + +/** + * File path filter function type for ADR filenames + * @public + */ +export type AdrFilePathFilterFn = (path: string) => boolean; + +/** + * File path filter for MADR filename formats + * @public + */ +export const madrFilePathFilter: AdrFilePathFilterFn = (path: string) => + /^\d{4}-.+\.md$/.test(path); + +/** + * ADR indexable document interface + * @public + */ +export interface AdrDocument extends IndexableDocument { + /** + * ADR status label + */ + status?: string; + /** + * ADR date + */ + date?: string; +} diff --git a/plugins/adr/.eslintrc.js b/plugins/adr/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/adr/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/adr/README.md b/plugins/adr/README.md new file mode 100644 index 0000000000..2888ff2f07 --- /dev/null +++ b/plugins/adr/README.md @@ -0,0 +1,126 @@ +# Architecture Decision Records (ADR) Plugin + +Welcome to the ADR plugin! + +This plugin allows you to browse ADRs associated with your entities as well as a way to discover ADRs across others entities via Backstage Search. Use this to learn from the past experience of other projects to guide your own architecture decisions. + +NOTE: This plugin currently only supports entities/ADRs registered via GitHub integration. + +## Setup + +Install this plugin: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-adr +``` + +### Entity Pages + +1. Add the plugin as a tab to your Entity pages: + +```jsx +// In packages/app/src/components/catalog/EntityPage.tsx +import { EntityAdrContent, isAdrAvailable } from '@backstage/plugin-adr'; + +... + +const serviceEntityPage = ( + + {/* other tabs... */} + + + + +``` + +2. Add `backstage.io/adr-location` annotation to your `catalog-info.yaml`: + +```yaml +metadata: + annotations: + backstage.io/adr-location: +``` + +The value for `backstage.io/adr-location` should be a path relative to your `catalog-info.yaml` file or a absolute URL to the directory which contains your ADR markdown files. + +For example, if you have the following directory structure, you would set `backstage.io/adr-location: docs/adrs`: + +``` +repo-root/ + README.md + src/ + catalog-info.yaml + docs/ + adrs/ + 0001-use-adrs.md + 0002-use-cloud.md +``` + +### Search + +First, make sure to setup Backstage Search with the [ADR backend plugin](../adr-backend/README.md). +Afterwards, add the following code snippet to use `AdrSearchResultListItem` when the type of the search results is `adr`: + +```tsx +// In packages/app/src/components/search/SearchPage.tsx +import { AdrSearchResultListItem } from '@backstage/plugin-adr'; + +... + +case 'adr': + return ( + + ); +``` + +## Custom ADR formats + +By default, this plugin will parse ADRs according to the format specified by the [Markdown Architecture Decision Record (MADR)](https://adr.github.io/madr/) template. If your ADRs are written using a different format, you can apply the following customizations to correctly identify and parse your documents: + +### Custom Filename/Path Format + +In order to ensure the plugin fetches the correct ADR files (e.g. ignoring your template file), you can pass in an optional `filePathFilterFn` parameter to `EntityAdrContent` which will be called with each file path relative to the ADR location specified by `backstage.io/adr-location`. For example, the follow custom filter function will ignore the ADR template file and include files with a specific naming convention including those under a specified sub-directory: + +```tsx +const myCustomFilterFn: AdrFilePathFilterFn = (path: string): boolean => { + if (path === '0000-adr-template.md') { + return false; + } + // Match all files following the pattern NNNN-title-with-dashes.md including those under decided-adrs/ + return /^(decided-adrs\/)?\d{4}-.+\.md$/.test(path); +} + +... + + +``` + +### Custom Content Decorators + +Your ADR Markdown content will typically be rendered in the UI as is with the exception of relative links/embeds being rewritten as absolute URLs so they can be linked correctly (e.g. `./my-diagram.png` => `/my-diagram.png`). Depending on your ADR format, you may want to apply additional transformations to the content (e.g. parsing/ignoring front matter). You can do so by passing in a list of custom content decorators for the optional `contentDecorators` parameter. Note that passing in this parameter will override the default decorators. If you want to include the default ones, make sure to add them as well: + +```tsx +import { + AdrReader, + ... +} from '@backstage/plugin-adr'; + +... + +const myCustomDecorator: AdrContentDecorator = ({ content }) => { + return { content: applyCustomContentTransformation(content) }; +}; + +... + + +``` diff --git a/plugins/adr/api-report.md b/plugins/adr/api-report.md new file mode 100644 index 0000000000..7d49689fc2 --- /dev/null +++ b/plugins/adr/api-report.md @@ -0,0 +1,64 @@ +## API Report File for "@backstage/plugin-adr" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { AdrDocument } from '@backstage/plugin-adr-common'; +import { AdrFilePathFilterFn } from '@backstage/plugin-adr-common'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { isAdrAvailable } from '@backstage/plugin-adr-common'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public +export type AdrContentDecorator = (adrInfo: { + baseUrl: string; + content: string; +}) => { + content: string; +}; + +// @public +export const adrPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// @public +export const AdrReader: { + ({ + adr, + decorators, + }: { + adr: string; + decorators?: AdrContentDecorator[] | undefined; + }): JSX.Element; + decorators: Readonly<{ + createRewriteRelativeLinksDecorator(): AdrContentDecorator; + createRewriteRelativeEmbedsDecorator(): AdrContentDecorator; + }>; +}; + +// @public +export const AdrSearchResultListItem: ({ + lineClamp, + result, +}: { + lineClamp?: number | undefined; + result: AdrDocument; +}) => JSX.Element; + +// @public +export const EntityAdrContent: ({ + contentDecorators, + filePathFilterFn, +}: { + contentDecorators?: AdrContentDecorator[] | undefined; + filePathFilterFn?: AdrFilePathFilterFn | undefined; +}) => JSX.Element; + +export { isAdrAvailable }; +``` diff --git a/plugins/adr/package.json b/plugins/adr/package.json new file mode 100644 index 0000000000..0f3b220ce5 --- /dev/null +++ b/plugins/adr/package.json @@ -0,0 +1,62 @@ +{ + "name": "@backstage/plugin-adr", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-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/core-components": "^0.9.3", + "@backstage/core-plugin-api": "^1.0.1", + "@backstage/integration-react": "^1.1.0-next.0", + "@backstage/plugin-adr-common": "^0.0.0", + "@backstage/plugin-catalog-react": "^1.1.0-next.0", + "@backstage/theme": "^0.2.15", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "git-url-parse": "^11.6.0", + "octokit": "^1.7.1", + "react-markdown": "^8.0.0", + "react-router-dom": "6.0.0-beta.0", + "react-text-truncate": "^0.18.0", + "react-use": "^17.2.4", + "remark-gfm": "^3.0.1" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.17.1-next.0", + "@backstage/core-app-api": "^1.0.1", + "@backstage/dev-utils": "^1.0.2-next.0", + "@backstage/test-utils": "^1.0.2-next.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/git-url-parse": "^9.0.0", + "@types/jest": "*", + "@types/node": "*", + "cross-fetch": "^3.1.5", + "msw": "^0.35.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/adr/src/components/AdrReader/AdrReader.tsx b/plugins/adr/src/components/AdrReader/AdrReader.tsx new file mode 100644 index 0000000000..ae7dc6c271 --- /dev/null +++ b/plugins/adr/src/components/AdrReader/AdrReader.tsx @@ -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 React, { useMemo } from 'react'; +import { + InfoCard, + MarkdownContent, + Progress, + WarningPanel, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { getAdrLocationUrl } from '@backstage/plugin-adr-common'; +import { useEntity } from '@backstage/plugin-catalog-react'; + +import { useOctokitRequest } from '../../hooks'; +import { adrDecoratorFactories } from './decorators'; +import { AdrContentDecorator } from './types'; + +/** + * Component to fetch and render an ADR. + * @public + */ +export const AdrReader = ({ + adr, + decorators, +}: { + adr: string; + decorators?: AdrContentDecorator[]; +}) => { + const { entity } = useEntity(); + const scmIntegrations = useApi(scmIntegrationsApiRef); + const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations); + + const { value, loading, error } = useOctokitRequest( + `${adrLocationUrl}/${adr}`, + ); + + const adrContent = useMemo(() => { + if (!value?.data) { + return ''; + } + const adrDecorators = decorators ?? [ + adrDecoratorFactories.createRewriteRelativeLinksDecorator(), + adrDecoratorFactories.createRewriteRelativeEmbedsDecorator(), + ]; + + return adrDecorators.reduce( + (content, decorator) => + decorator({ baseUrl: adrLocationUrl, content }).content, + value.data, + ); + }, [adrLocationUrl, decorators, value]); + + return ( + + {loading && } + + {!loading && error && ( + + )} + + {!loading && !error && value?.data && ( + + )} + + ); +}; + +AdrReader.decorators = adrDecoratorFactories; diff --git a/plugins/adr/src/components/AdrReader/decorators.ts b/plugins/adr/src/components/AdrReader/decorators.ts new file mode 100644 index 0000000000..f24bb48a00 --- /dev/null +++ b/plugins/adr/src/components/AdrReader/decorators.ts @@ -0,0 +1,47 @@ +/* + * 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 { AdrContentDecorator } from './types'; + +/** + * + * Factory for creating default ADR content decorators. The adrDecoratorFactories + * symbol is not directly exported, but through the AdrReader.decorators field. + * @public + */ +export const adrDecoratorFactories = Object.freeze({ + /** + * Rewrites relative Markdown links as absolute links. + */ + createRewriteRelativeLinksDecorator(): AdrContentDecorator { + return ({ baseUrl, content }) => ({ + content: content.replace( + /\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.md)\)/gim, + `[$1](${baseUrl}/$2$3)`, + ), + }); + }, + /** + * Rewrites relative Markdown embeds using absolute URLs. + */ + createRewriteRelativeEmbedsDecorator(): AdrContentDecorator { + return ({ baseUrl, content }) => ({ + content: content.replace( + /!\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.png|\.jpg|\.jpeg|\.gif|\.webp)(.*)\)/gim, + `![$1](${baseUrl}/$2$3$4)`, + ), + }); + }, +}); diff --git a/plugins/adr/src/components/AdrReader/index.ts b/plugins/adr/src/components/AdrReader/index.ts new file mode 100644 index 0000000000..8482351016 --- /dev/null +++ b/plugins/adr/src/components/AdrReader/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 './AdrReader'; +export * from './types'; diff --git a/plugins/adr/src/components/AdrReader/types.ts b/plugins/adr/src/components/AdrReader/types.ts new file mode 100644 index 0000000000..f12906c00b --- /dev/null +++ b/plugins/adr/src/components/AdrReader/types.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. + */ + +/** + * ADR content decorator function type. Decorators are responsible for + * performing any necessary transformations on the ADR content before rendering. + * @public + */ +export type AdrContentDecorator = (adrInfo: { + baseUrl: string; + content: string; +}) => { content: string }; diff --git a/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx b/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx new file mode 100644 index 0000000000..bd8e4be3cf --- /dev/null +++ b/plugins/adr/src/components/EntityAdrContent/EntityAdrContent.tsx @@ -0,0 +1,155 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { + Content, + ContentHeader, + MissingAnnotationEmptyState, + Progress, + SupportButton, + WarningPanel, +} from '@backstage/core-components'; +import { useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { + AdrFilePathFilterFn, + ANNOTATION_ADR_LOCATION, + getAdrLocationUrl, + isAdrAvailable, + madrFilePathFilter, +} from '@backstage/plugin-adr-common'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + Grid, + List, + ListItem, + ListItemText, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; + +import { useOctokitRequest } from '../../hooks'; +import { rootRouteRef } from '../../routes'; +import { AdrContentDecorator, AdrReader } from '../AdrReader'; + +const useStyles = makeStyles((theme: Theme) => ({ + adrMenu: { + backgroundColor: theme.palette.background.paper, + }, +})); + +/** + * Component for browsing ADRs on an entity page. + * @public + */ +export const EntityAdrContent = ({ + contentDecorators, + filePathFilterFn, +}: { + contentDecorators?: AdrContentDecorator[]; + filePathFilterFn?: AdrFilePathFilterFn; +}) => { + const classes = useStyles(); + const { entity } = useEntity(); + const rootLink = useRouteRef(rootRouteRef); + const [adrList, setAdrList] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); + const scmIntegrations = useApi(scmIntegrationsApiRef); + const entityHasAdrs = isAdrAvailable(entity); + + const { value, loading, error } = useOctokitRequest( + getAdrLocationUrl(entity, scmIntegrations), + ); + + const selectedAdr = + adrList.find(adr => adr === searchParams.get('record')) ?? ''; + useEffect(() => { + if (adrList.length && !selectedAdr) { + searchParams.set('record', adrList[0]); + setSearchParams(searchParams, { replace: true }); + } + }); + + useEffect(() => { + if (!value?.data) { + return; + } + + const adrs = value.data + .filter( + (item: { type: string; name: string }) => + item.type === 'file' && + (filePathFilterFn + ? filePathFilterFn(item.name) + : madrFilePathFilter(item.name)), + ) + .map(({ name }: { name: string }) => name); + + setAdrList(adrs); + }, [filePathFilterFn, value]); + + return ( + + + + + + {!entityHasAdrs && ( + + )} + + {loading && } + + {entityHasAdrs && !loading && error && ( + + )} + + {entityHasAdrs && + !loading && + !error && + (adrList.length ? ( + + + + {adrList.map((adr, idx) => ( + + + + ))} + + + + + + + ) : ( + No ADRs found + ))} + + ); +}; diff --git a/plugins/adr/src/components/EntityAdrContent/index.ts b/plugins/adr/src/components/EntityAdrContent/index.ts new file mode 100644 index 0000000000..4552d4e87d --- /dev/null +++ b/plugins/adr/src/components/EntityAdrContent/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 { EntityAdrContent } from './EntityAdrContent'; diff --git a/plugins/adr/src/hooks/index.ts b/plugins/adr/src/hooks/index.ts new file mode 100644 index 0000000000..d5aa102fb8 --- /dev/null +++ b/plugins/adr/src/hooks/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 * from './useOctokitRequest'; diff --git a/plugins/adr/src/hooks/useOctokitRequest.ts b/plugins/adr/src/hooks/useOctokitRequest.ts new file mode 100644 index 0000000000..f160651169 --- /dev/null +++ b/plugins/adr/src/hooks/useOctokitRequest.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 parseGitUrl from 'git-url-parse'; +import useAsync from 'react-use/lib/useAsync'; +import { Octokit } from 'octokit'; +import { useApi } from '@backstage/core-plugin-api'; +import { + scmAuthApiRef, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; + +/** + * Hook for triggering authenticated Octokit requests against the GitHub Content API + * @public + */ +export const useOctokitRequest = (request: string): any => { + const authApi = useApi(scmAuthApiRef); + const scmIntegrations = useApi(scmIntegrationsApiRef); + + const { owner, name, ref, filepath } = parseGitUrl(request); + const path = filepath.replace(/^\//, ''); + const baseUrl = scmIntegrations.github.byUrl(request)?.config.apiBaseUrl; + + return useAsync(async () => { + const { token } = await authApi.getCredentials({ + url: request, + additionalScope: { + customScopes: { + github: ['repo'], + }, + }, + }); + const octokit = new Octokit({ + auth: token, + baseUrl, + }); + + return octokit.request( + `GET /repos/${owner}/${name}/contents/${path}?ref=${ref}`, + { + headers: { Accept: 'application/vnd.github.v3.raw' }, + }, + ); + }, [request]); +}; diff --git a/plugins/adr/src/index.ts b/plugins/adr/src/index.ts new file mode 100644 index 0000000000..84012ccb7a --- /dev/null +++ b/plugins/adr/src/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ +/** + * ADR frontend plugin + * + * @packageDocumentation + */ +export { isAdrAvailable } from '@backstage/plugin-adr-common'; +export * from './components/AdrReader'; +export { adrPlugin, EntityAdrContent } from './plugin'; +export * from './search'; diff --git a/plugins/adr/src/plugin.test.ts b/plugins/adr/src/plugin.test.ts new file mode 100644 index 0000000000..e311bf98e6 --- /dev/null +++ b/plugins/adr/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 { adrPlugin } from './plugin'; + +describe('adr', () => { + it('should export plugin', () => { + expect(adrPlugin).toBeDefined(); + }); +}); diff --git a/plugins/adr/src/plugin.ts b/plugins/adr/src/plugin.ts new file mode 100644 index 0000000000..03437b1f0b --- /dev/null +++ b/plugins/adr/src/plugin.ts @@ -0,0 +1,46 @@ +/* + * 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, +} from '@backstage/core-plugin-api'; + +import { rootRouteRef } from './routes'; + +/** + * The Backstage plugin that holds ADR specific components + * @public + */ +export const adrPlugin = createPlugin({ + id: 'adr', + routes: { + root: rootRouteRef, + }, +}); + +/** + * An extension for browsing ADRs on an entity page. + * @public + */ +export const EntityAdrContent = adrPlugin.provide( + createRoutableExtension({ + name: 'EntityAdrContent', + component: () => + import('./components/EntityAdrContent').then(m => m.EntityAdrContent), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/adr/src/routes.ts b/plugins/adr/src/routes.ts new file mode 100644 index 0000000000..0ec5c71294 --- /dev/null +++ b/plugins/adr/src/routes.ts @@ -0,0 +1,24 @@ +/* + * 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'; + +/** + * Root route ref for the ADR plugin + * @public + */ +export const rootRouteRef = createRouteRef({ + id: 'adr', +}); diff --git a/plugins/adr/src/search/AdrSearchResultListItem.tsx b/plugins/adr/src/search/AdrSearchResultListItem.tsx new file mode 100644 index 0000000000..8b89629af6 --- /dev/null +++ b/plugins/adr/src/search/AdrSearchResultListItem.tsx @@ -0,0 +1,80 @@ +/* + * 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 TextTruncate from 'react-text-truncate'; +import { + Box, + Chip, + Divider, + ListItem, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import { Link } from '@backstage/core-components'; +import { AdrDocument } from '@backstage/plugin-adr-common'; + +const useStyles = makeStyles({ + flexContainer: { + flexWrap: 'wrap', + }, + itemText: { + width: '100%', + wordBreak: 'break-all', + marginBottom: '1rem', + }, +}); + +/** + * A component to display a ADR search result + * @public + */ +export const AdrSearchResultListItem = ({ + lineClamp = 5, + result, +}: { + lineClamp?: number; + result: AdrDocument; +}) => { + const classes = useStyles(); + + return ( + + + + } + /> + + {result.status && ( + + )} + {result.date && } + + + + + ); +}; diff --git a/plugins/adr/src/search/index.ts b/plugins/adr/src/search/index.ts new file mode 100644 index 0000000000..519d8cb91f --- /dev/null +++ b/plugins/adr/src/search/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 * from './AdrSearchResultListItem'; diff --git a/plugins/adr/src/setupTests.ts b/plugins/adr/src/setupTests.ts new file mode 100644 index 0000000000..9bb3e72355 --- /dev/null +++ b/plugins/adr/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/yarn.lock b/yarn.lock index 2754e61e43..6ce1b2240e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6301,6 +6301,11 @@ resolved "https://registry.npmjs.org/@types/luxon/-/luxon-2.3.1.tgz#e34763178b46232e4c5f079f1706e18692415519" integrity sha512-nAPUltOT28fal2eDZz8yyzNhBjHw1NEymFBP7Q9iCShqpflWPybxHbD7pw/46jQmT+HXOy1QN5hNTms8MOTlOQ== +"@types/marked@^4.0.0": + version "4.0.3" + resolved "https://registry.npmjs.org/@types/marked/-/marked-4.0.3.tgz#2098f4a77adaba9ce881c9e0b6baf29116e5acc4" + integrity sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg== + "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.10" resolved "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz#4724244a82a4598884cbbe9bcfd73dff927ee8af" @@ -18467,7 +18472,7 @@ node-fetch@2.6.1: resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@2.6.7, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.5, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==