feat: add readme card plugin on azure devops

Signed-off-by: Alisson Fabiano <afabiano@eshopworld.com>
This commit is contained in:
Alisson Fabiano
2022-08-11 16:07:10 +01:00
parent af1e11b744
commit bd34a1e7d4
10 changed files with 333 additions and 2 deletions
@@ -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<Project[]> {
@@ -393,6 +396,24 @@ export class AzureDevOpsApi {
return buildRuns;
}
public async getReadme(
host: string,
organization: string,
projectName: string,
repoName: string,
): Promise<string> {
const getContentFile = async (
path: string,
encoding?: BufferEncoding,
): Promise<string> => {
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 {
@@ -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)
`;
}
@@ -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;
}
@@ -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<string>,
) {
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] || ''
);
}
+10
View File
@@ -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;
@@ -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<Readme | undefined>;
}
@@ -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<Readme | undefined> => {
try {
return await this.get(
`readme/${encodeURIComponent(opts.project)}/${encodeURIComponent(
opts.repo,
)}`,
);
} catch (e) {
if (isNotFoundError(e)) {
return undefined;
}
return e;
}
};
private async get<T>(path: string): Promise<T> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`;
const url = new URL(path, baseUrl);
@@ -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 <Progress />;
} else if (error) {
return (
<Alert severity="error" className={classes.infoCard}>
{error.message}
</Alert>
);
}
if (!value)
return (
<EmptyState
title="No README available for this entity"
missing="field"
description="You can add a README to your entity by following the Azure DevOps documentation."
action={
<Button
variant="contained"
color="primary"
href="https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops"
>
Read more
</Button>
}
/>
);
return (
<InfoCard
title="Readme"
className={classes.infoCard}
deepLink={{
link: value!.url,
title: 'Readme',
onClick: e => {
e.preventDefault();
window.open(value?.url);
},
}}
>
<div
className={classes.readMe}
style={{
maxHeight: `${props.maxHeight}px`,
}}
>
<MarkdownContent content={value?.content ?? ''} />
</div>
</InfoCard>
);
};
@@ -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';
+1
View File
@@ -19,6 +19,7 @@ export {
EntityAzurePipelinesContent,
EntityAzureGitTagsContent,
EntityAzurePullRequestsContent,
EntityAzureReadMeContent,
isAzureDevOpsAvailable,
isAzurePipelinesAvailable,
AzurePullRequestsPage,