feat(ADR): implement ADR plugin

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2022-04-13 14:39:17 -04:00
parent 1cb9cec16b
commit e73075a301
37 changed files with 1679 additions and 1 deletions
@@ -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 (
<InfoCard>
{loading && <Progress />}
{!loading && error && (
<WarningPanel title="Failed to fetch ADR" message={error?.message} />
)}
{!loading && !error && value?.data && (
<MarkdownContent content={adrContent} linkTarget="_blank" />
)}
</InfoCard>
);
};
AdrReader.decorators = adrDecoratorFactories;
@@ -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)`,
),
});
},
});
@@ -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';
@@ -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 };
@@ -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<string[]>([]);
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 (
<Content>
<ContentHeader title="Architecture Decision Records">
<SupportButton />
</ContentHeader>
{!entityHasAdrs && (
<MissingAnnotationEmptyState annotation={ANNOTATION_ADR_LOCATION} />
)}
{loading && <Progress />}
{entityHasAdrs && !loading && error && (
<WarningPanel title="Failed to fetch ADRs" message={error?.message} />
)}
{entityHasAdrs &&
!loading &&
!error &&
(adrList.length ? (
<Grid container direction="row">
<Grid item xs={3}>
<List className={classes.adrMenu} dense>
{adrList.map((adr, idx) => (
<ListItem
key={idx}
button
component={Link}
to={`${rootLink()}?record=${adr}`}
selected={selectedAdr === adr}
>
<ListItemText
secondaryTypographyProps={{ noWrap: true }}
secondary={adr.replace(/\.md$/, '')}
/>
</ListItem>
))}
</List>
</Grid>
<Grid item xs={9}>
<AdrReader adr={selectedAdr} decorators={contentDecorators} />
</Grid>
</Grid>
) : (
<Typography>No ADRs found</Typography>
))}
</Content>
);
};
@@ -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';
+16
View File
@@ -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';
@@ -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<any>(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]);
};
+24
View File
@@ -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';
+22
View File
@@ -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();
});
});
+46
View File
@@ -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,
}),
);
+24
View File
@@ -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',
});
@@ -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 (
<Link to={result.location}>
<ListItem alignItems="flex-start" className={classes.flexContainer}>
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={result.title}
secondary={
<TextTruncate
line={lineClamp}
truncateText="…"
text={result.text}
element="span"
/>
}
/>
<Box>
{result.status && (
<Chip label={`Status: ${result.status}`} size="small" />
)}
{result.date && <Chip label={`Date: ${result.date}`} size="small" />}
</Box>
</ListItem>
<Divider component="li" />
</Link>
);
};
+16
View File
@@ -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';
+17
View File
@@ -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';