feat: add readme card plugin on azure devops
Signed-off-by: Alisson Fabiano <afabiano@eshopworld.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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] || ''
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
EntityAzurePipelinesContent,
|
||||
EntityAzureGitTagsContent,
|
||||
EntityAzurePullRequestsContent,
|
||||
EntityAzureReadMeContent,
|
||||
isAzureDevOpsAvailable,
|
||||
isAzurePipelinesAvailable,
|
||||
AzurePullRequestsPage,
|
||||
|
||||
Reference in New Issue
Block a user