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,
+ ``,
+ ),
+ });
+ },
+});
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==