From bd34a1e7d4acefd0162eea0e012c82eabba5db10 Mon Sep 17 00:00:00 2001 From: Alisson Fabiano Date: Thu, 11 Aug 2022 16:07:10 +0100 Subject: [PATCH] feat: add readme card plugin on azure devops Signed-off-by: Alisson Fabiano --- .../src/api/AzureDevOpsApi.ts | 21 +++ .../src/service/router.test.ts | 58 ++++++++ .../src/service/router.ts | 19 ++- .../src/utils/azure-devops-utils.ts | 50 +++++++ plugins/azure-devops-common/src/types.ts | 10 ++ .../azure-devops/src/api/AzureDevOpsApi.ts | 4 + .../azure-devops/src/api/AzureDevOpsClient.ts | 23 +++ .../src/components/ReadMeCard/ReadMeCard.tsx | 132 ++++++++++++++++++ .../src/components/ReadMeCard/index.ts | 17 +++ plugins/azure-devops/src/index.ts | 1 + 10 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 plugins/azure-devops/src/components/ReadMeCard/ReadMeCard.tsx create mode 100644 plugins/azure-devops/src/components/ReadMeCard/index.ts diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 240ba0b189..453f28809a 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -42,6 +42,7 @@ import { convertDashboardPullRequest, convertPolicy, getArtifactId, + replaceReadme, } from '../utils'; import { TeamMember as AdoTeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; @@ -52,11 +53,13 @@ import { TeamProjectReference, WebApiTeam, } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { UrlReader } from '@backstage/backend-common'; export class AzureDevOpsApi { public constructor( private readonly logger: Logger, private readonly webApi: WebApi, + private readonly urlReader: UrlReader, ) {} public async getProjects(): Promise { @@ -393,6 +396,24 @@ export class AzureDevOpsApi { return buildRuns; } + + public async getReadme( + host: string, + organization: string, + projectName: string, + repoName: string, + ): Promise { + const getContentFile = async ( + path: string, + encoding?: BufferEncoding, + ): Promise => { + const url = `https://${host}/${organization}/${projectName}/_git/${repoName}?path=${path}`; + const response = await this.urlReader.read(url); + return Buffer.from(response).toString(encoding); + }; + const readmeContent = await getContentFile('README.md'); + return await replaceReadme(readmeContent, getContentFile); + } } export function mappedRepoBuild(build: Build): RepoBuild { diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index 3bf18de6a2..063d80b9a0 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -53,6 +53,7 @@ describe('createRouter', () => { getBuildRuns: jest.fn(), getAllTeams: jest.fn(), getTeamMembers: jest.fn(), + getReadme: jest.fn(), } as any; const router = await createRouter({ azureDevOpsApi, @@ -455,4 +456,61 @@ describe('createRouter', () => { expect(response.status).toEqual(200); }); }); + + describe('GET /readme/:projectName/:repoName', () => { + it('fetches readme file', async () => { + const readmeMock = getReadmeMock(); + azureDevOpsApi.getReadme.mockResolvedValueOnce(readmeMock); + const response = await request(app).get( + '/readme/myProject/myRepo?path=README.md', + ); + expect(azureDevOpsApi.getReadme).toHaveBeenCalledWith( + 'host.com', + 'myOrg', + 'myProject', + 'myRepo', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + content: readmeMock, + url: `https://host.com/myOrg/myProject/_git/myRepo?path=README.md`, + }); + }); + }); }); + +function getReadmeMock() { + return ` + # Introduction + TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. + + # Getting Started + TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: + 1. Installation process + 2. Software dependencies + 3. Latest releases + 4. API references + + # Build and Test + TODO: Describe and show how to build your code and run the tests. + + # Contribute + TODO: Explain how other users and developers can contribute to make your code better. + + If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: + - [ASP.NET Core](https://github.com/aspnet/Home) + - [Visual Studio Code](https://github.com/Microsoft/vscode) + - [Chakra Core](https://github.com/Microsoft/ChakraCore) + + + - ![Imagem 1](./images/image1.jpg) + - ![Imagem 2](./images/image2.png) + - ![Imagem 3](./images/image3.jpg) + - ![Imagem 4](./images/image4.webp) + - ![Imagem 5](./images/image5.png) + - ![Imagem 6](/images/image6.png) + - ![Imagem 7](/images/image-7.jpg) + - ![Imagem 8](./images/image-8.gif) + - ![Imagem 9](/images/image9.png) + `; +} diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index a4a76b4a14..36bacba8be 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -26,7 +26,7 @@ import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { PullRequestsDashboardProvider } from '../api/PullRequestsDashboardProvider'; import Router from 'express-promise-router'; -import { errorHandler } from '@backstage/backend-common'; +import { errorHandler, UrlReaders } from '@backstage/backend-common'; import express from 'express'; const DEFAULT_TOP = 10; @@ -46,12 +46,13 @@ export async function createRouter( const token = config.getString('token'); const host = config.getString('host'); const organization = config.getString('organization'); + const reader = UrlReaders.default(options); const authHandler = getPersonalAccessTokenHandler(token); const webApi = new WebApi(`https://${host}/${organization}`, authHandler); const azureDevOpsApi = - options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi); + options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi, reader); const pullRequestsDashboardProvider = await PullRequestsDashboardProvider.create(logger, azureDevOpsApi); @@ -191,6 +192,20 @@ export async function createRouter( res.status(200).json(teamIds); }); + router.get('/readme/:projectName/:repoName', async (req, res) => { + const { projectName, repoName } = req.params; + const content = await azureDevOpsApi.getReadme( + host, + organization, + projectName, + repoName, + ); + res.status(200).json({ + content, + url: `https://${host}/${organization}/${projectName}/_git/${repoName}?path=README.md`, + }); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts index 7a0d0a279e..0627fba1f3 100644 --- a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts @@ -33,6 +33,7 @@ import { import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; +import PathUtils from 'path'; export function convertDashboardPullRequest( pullRequest: GitPullRequest, @@ -205,6 +206,29 @@ export function convertPolicy( }; } +export async function replaceReadme( + content: string, + getContentFile: (path: string, encoding?: BufferEncoding) => Promise, +) { + const regExp = + /\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.png|\.jpg|\.jpeg|\.gif|\.webp)(.*)\)/gim; + const filesPath = content.match(regExp) || []; + + if (filesPath.length === 0) return content; + + let replacedContent = content; + for (const filePath of filesPath) { + const { label, path, ext } = getPartsFromFilePath(filePath); + const mime = getMimeByExtension(ext); + const base64 = await getContentFile(path, 'base64'); + replacedContent = replacedContent.replace( + filePath, + `[${label}](data:${mime};base64,${base64})`, + ); + } + return replacedContent; +} + function convertReviewer( identityRef?: IdentityRefWithVote, ): Reviewer | undefined { @@ -263,3 +287,29 @@ function convertCreatedBy(identityRef?: IdentityRef): CreatedBy | undefined { function hasAutoComplete(pullRequest: GitPullRequest): boolean { return pullRequest.isDraft !== true && !!pullRequest.completionOptions; } + +function getPartsFromFilePath(content: string): { + label: string; + path: string; + ext: string; +} { + const regExp = /\[(.*?)\]\(([^)]+)\)/; + const [_, label, path] = regExp.exec(content) || []; + return { + label, + path: path.startsWith('.') ? path.substring(1, path.length) : path, + ext: PathUtils.extname(path), + }; +} + +function getMimeByExtension(ext: string): string { + return ( + { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + }[ext] || '' + ); +} diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index db2813dec1..f29b134ed9 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -192,6 +192,16 @@ export interface Team { members?: string[]; } +export interface ReadmeConfig { + project: string; + repo: string; +} + +export interface Readme { + url: string; + content: string; +} + export interface TeamMember { id?: string; displayName?: string; diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index ccedc401f1..1959d0389a 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -21,6 +21,8 @@ import { GitTag, PullRequest, PullRequestOptions, + Readme, + ReadmeConfig, RepoBuild, RepoBuildOptions, Team, @@ -64,4 +66,6 @@ export interface AzureDevOpsApi { definitionName?: string, options?: BuildRunOptions, ): Promise<{ items: BuildRun[] }>; + + getReadme(opts: ReadmeConfig): Promise; } diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index 61c8479d71..f9384a0549 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -21,6 +21,8 @@ import { GitTag, PullRequest, PullRequestOptions, + Readme, + ReadmeConfig, RepoBuild, RepoBuildOptions, Team, @@ -30,6 +32,10 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { AzureDevOpsApi } from './AzureDevOpsApi'; import { ResponseError } from '@backstage/errors'; +function isNotFoundError(error: any): error is ResponseError { + return error.response.status === 404; +} + export class AzureDevOpsClient implements AzureDevOpsApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; @@ -130,6 +136,23 @@ export class AzureDevOpsClient implements AzureDevOpsApi { return { items }; } + public getReadme = async ( + opts: ReadmeConfig, + ): Promise => { + try { + return await this.get( + `readme/${encodeURIComponent(opts.project)}/${encodeURIComponent( + opts.repo, + )}`, + ); + } catch (e) { + if (isNotFoundError(e)) { + return undefined; + } + return e; + } + }; + private async get(path: string): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`; const url = new URL(path, baseUrl); diff --git a/plugins/azure-devops/src/components/ReadMeCard/ReadMeCard.tsx b/plugins/azure-devops/src/components/ReadMeCard/ReadMeCard.tsx new file mode 100644 index 0000000000..662af0d004 --- /dev/null +++ b/plugins/azure-devops/src/components/ReadMeCard/ReadMeCard.tsx @@ -0,0 +1,132 @@ +/* + * 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 { Button, makeStyles } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { + InfoCard, + Progress, + MarkdownContent, + EmptyState, +} from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useProjectRepoFromEntity } from '../../hooks'; +import { useApi } from '@backstage/core-plugin-api'; +import React from 'react'; +import { azureDevOpsApiRef } from '../../api'; +import useAsync from 'react-use/lib/useAsync'; + +const useStyles = makeStyles(theme => ({ + infoCard: { + marginBottom: theme.spacing(3), + '& + .MuiAlert-root': { + marginTop: theme.spacing(3), + }, + '& .MuiCardContent-root': { + padding: theme.spacing(2, 1, 2, 2), + }, + }, + readMe: { + overflowY: 'auto', + paddingRight: theme.spacing(1), + '&::-webkit-scrollbar-track': { + backgroundColor: '#F5F5F5', + borderRadius: '5px', + }, + '&::-webkit-scrollbar': { + width: '5px', + backgroundColor: '#F5F5F5', + borderRadius: '5px', + }, + '&::-webkit-scrollbar-thumb': { + border: '1px solid #555555', + backgroundColor: '#555', + borderRadius: '4px', + }, + }, +})); + +type Props = { + maxHeight?: number; +}; + +export const ReadMeCard = (props: Props) => { + const classes = useStyles(); + const api = useApi(azureDevOpsApiRef); + const { entity } = useEntity(); + const { project, repo } = useProjectRepoFromEntity(entity); + + const { loading, error, value } = useAsync( + () => + api.getReadme({ + project, + repo, + }), + [api, project, repo, entity], + ); + + if (loading) { + return ; + } else if (error) { + return ( + + {error.message} + + ); + } + + if (!value) + return ( + + Read more + + } + /> + ); + + return ( + { + e.preventDefault(); + window.open(value?.url); + }, + }} + > +
+ +
+
+ ); +}; diff --git a/plugins/azure-devops/src/components/ReadMeCard/index.ts b/plugins/azure-devops/src/components/ReadMeCard/index.ts new file mode 100644 index 0000000000..7c53fb481b --- /dev/null +++ b/plugins/azure-devops/src/components/ReadMeCard/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 { ReadMeCard } from './ReadMeCard'; diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index e65c1d02a3..84bab54cbf 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -19,6 +19,7 @@ export { EntityAzurePipelinesContent, EntityAzureGitTagsContent, EntityAzurePullRequestsContent, + EntityAzureReadMeContent, isAzureDevOpsAvailable, isAzurePipelinesAvailable, AzurePullRequestsPage,