Merge pull request #13143 from alissonfabiano/af/add-readme-card-azure-devops

[Azure DevOps] add README card
This commit is contained in:
Fredrik Adelöw
2022-08-22 12:01:32 +02:00
committed by GitHub
22 changed files with 666 additions and 16 deletions
+39
View File
@@ -0,0 +1,39 @@
---
'@backstage/plugin-azure-devops-backend': patch
---
`createRouter` now requires an additional reader: `UrlReader` argument
```diff
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return createRouter({
logger: env.logger,
config: env.config,
+ reader: env.reader,
});
}
```
Remember to check if you have already provided these settings previously.
#### [Azure DevOps]
```yaml
# app-config.yaml
azureDevOps:
host: dev.azure.com
token: my-token
organization: my-company
```
#### [Azure Integrations]
```yaml
# app-config.yaml
integrations:
azure:
- host: dev.azure.com
token: ${AZURE_TOKEN}
```
+49
View File
@@ -0,0 +1,49 @@
---
'@backstage/plugin-azure-devops': minor
'@backstage/plugin-azure-devops-common': minor
---
Added README card `EntityAzureReadmeCard` for Azure Devops.
To get the README component working you'll need to do the following two steps:
1. First we need to add the @backstage/plugin-azure-devops package to your frontend app:
```bash
# From your Backstage root directory
yarn add --cwd packages/app @backstage/plugin-azure-devops
```
2. Second we need to add the `EntityAzureReadmeCard` extension to the entity page in your app:
```tsx
// In packages/app/src/components/catalog/EntityPage.tsx
import {
EntityAzureReadmeCard,
isAzureDevOpsAvailable,
} from '@backstage/plugin-azure-devops';
// As it is a card, you can customize it the way you prefer
// For example in the Service section
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
<EntitySwitch>
<EntitySwitch.Case if={isAzureDevOpsAvailable}>
<Grid item md={6}>
...
</Grid>
<Grid item md={6}>
<EntityAzureReadmeCard maxHeight={350} />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
</Grid>
);
```
**Notes:**
- You'll need to add the `EntitySwitch.Case` above from step 2 to all the entity sections you want to see Readme in. For example if you wanted to see Readme when looking at Website entities then you would need to add this to the `websiteEntityPage` section.
- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation
- The `maxHeight` property on the `EntityAzureReadmeCard` will set the maximum screen size you would like to see, if not set it will default to 100%
@@ -24,5 +24,6 @@ export default async function createPlugin(
return createRouter({
logger: env.logger,
config: env.config,
reader: env.reader,
});
}
+14 -1
View File
@@ -18,11 +18,12 @@ import { PullRequestOptions } from '@backstage/plugin-azure-devops-common';
import { RepoBuild } from '@backstage/plugin-azure-devops-common';
import { Team } from '@backstage/plugin-azure-devops-common';
import { TeamMember } from '@backstage/plugin-azure-devops-common';
import { UrlReader } from '@backstage/backend-common';
import { WebApi } from 'azure-devops-node-api';
// @public (undocumented)
export class AzureDevOpsApi {
constructor(logger: Logger, webApi: WebApi);
constructor(logger: Logger, webApi: WebApi, urlReader: UrlReader);
// (undocumented)
getAllTeams(): Promise<Team[]>;
// (undocumented)
@@ -71,6 +72,16 @@ export class AzureDevOpsApi {
options: PullRequestOptions,
): Promise<PullRequest[]>;
// (undocumented)
getReadme(
host: string,
org: string,
project: string,
repo: string,
): Promise<{
url: string;
content: string;
}>;
// (undocumented)
getRepoBuilds(
projectName: string,
repoName: string,
@@ -94,6 +105,8 @@ export interface RouterOptions {
config: Config;
// (undocumented)
logger: Logger;
// (undocumented)
reader: UrlReader;
}
// (No @packageDocumentation comment for this package)
+2 -1
View File
@@ -32,7 +32,8 @@
"express-promise-router": "^4.1.0",
"p-limit": "^3.1.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
"yn": "^4.0.0",
"mime-types": "^2.1.27"
},
"devDependencies": {
"@backstage/cli": "^0.18.1",
@@ -42,6 +42,8 @@ import {
convertDashboardPullRequest,
convertPolicy,
getArtifactId,
replaceReadme,
buildEncodedUrl,
} from '../utils';
import { TeamMember as AdoTeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
@@ -52,12 +54,14 @@ import {
TeamProjectReference,
WebApiTeam,
} from 'azure-devops-node-api/interfaces/CoreInterfaces';
import { UrlReader } from '@backstage/backend-common';
/** @public */
export class AzureDevOpsApi {
public constructor(
private readonly logger: Logger,
private readonly webApi: WebApi,
private readonly urlReader: UrlReader,
) {}
public async getProjects(): Promise<Project[]> {
@@ -393,6 +397,28 @@ export class AzureDevOpsApi {
return buildRuns;
}
public async getReadme(
host: string,
org: string,
project: string,
repo: string,
): Promise<{
url: string;
content: string;
}> {
const url = buildEncodedUrl(host, org, project, repo, 'README.md');
const response = await this.urlReader.read(url);
const content = await replaceReadme(
this.urlReader,
host,
org,
project,
repo,
response.toString(),
);
return { url, content };
}
}
export function mappedRepoBuild(build: Build): RepoBuild {
@@ -33,7 +33,7 @@ import { ConfigReader } from '@backstage/config';
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { createRouter } from './router';
import express from 'express';
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, UrlReaders } from '@backstage/backend-common';
import request from 'supertest';
describe('createRouter', () => {
@@ -53,19 +53,30 @@ describe('createRouter', () => {
getBuildRuns: jest.fn(),
getAllTeams: jest.fn(),
getTeamMembers: jest.fn(),
getReadme: jest.fn(),
} as any;
const config = new ConfigReader({
azureDevOps: {
token: 'foo',
host: 'host.com',
organization: 'myOrg',
top: 5,
},
});
const logger = getVoidLogger();
const router = await createRouter({
config,
logger,
azureDevOpsApi,
logger: getVoidLogger(),
config: new ConfigReader({
azureDevOps: {
token: 'foo',
host: 'host.com',
organization: 'myOrg',
top: 5,
},
reader: UrlReaders.default({
config,
logger,
}),
});
app = express().use(router);
});
@@ -455,4 +466,67 @@ describe('createRouter', () => {
expect(response.status).toEqual(200);
});
});
describe('GET /readme/:projectName/:repoName', () => {
it('fetches readme file', async () => {
const content = getReadmeMock();
const url = `https://host.com/myOrg/myProject/_git/myRepo?path=README.md`;
azureDevOpsApi.getReadme.mockResolvedValueOnce({
content,
url,
});
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,
url,
});
});
});
});
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, UrlReader } from '@backstage/backend-common';
import express from 'express';
const DEFAULT_TOP = 10;
@@ -36,13 +36,14 @@ export interface RouterOptions {
azureDevOpsApi?: AzureDevOpsApi;
logger: Logger;
config: Config;
reader: UrlReader;
}
/** @public */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger } = options;
const { logger, reader } = options;
const config = options.config.getConfig('azureDevOps');
const token = config.getString('token');
@@ -53,7 +54,7 @@ export async function createRouter(
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);
@@ -193,6 +194,17 @@ export async function createRouter(
res.status(200).json(teamIds);
});
router.get('/readme/:projectName/:repoName', async (req, res) => {
const { projectName, repoName } = req.params;
const readme = await azureDevOpsApi.getReadme(
host,
organization,
projectName,
repoName,
);
res.status(200).json(readme);
});
router.use(errorHandler());
return router;
}
@@ -17,6 +17,7 @@
import {
createServiceBuilder,
loadBackendConfig,
UrlReaders,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
@@ -39,6 +40,7 @@ export async function startStandaloneServer(
const router = await createRouter({
logger,
config,
reader: UrlReaders.default({ logger, config }),
});
let service = createServiceBuilder(module)
@@ -21,12 +21,15 @@ import {
} from '@backstage/plugin-azure-devops-common';
import {
convertDashboardPullRequest,
extractAssets,
extractPartsFromAsset,
getArtifactId,
getAvatarUrl,
getPullRequestLink,
replaceReadme,
} from './azure-devops-utils';
import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { UrlReader } from '@backstage/backend-common';
describe('convertDashboardPullRequest', () => {
it('should return DashboardPullRequest', () => {
@@ -159,3 +162,115 @@ describe('getArtifactId', () => {
expect(result).toBe('vstfs:///CodeReview/CodeReviewId/project1/1');
});
});
describe('extractAssets', () => {
it('should return assets', () => {
const readme = `
## Images
![Image 1](./images/sample-4(2).png)
![Image 2](./images/cdCSj+-012340.jpg)
![Image 3](/images/test-4(2)))).jpeg)
![Image 4](./images/test-2211jd.webp)
![Image 5](/images/sa)mple.gif)
`;
const result = extractAssets(readme);
expect(result).toEqual([
'[Image 1](./images/sample-4(2).png)',
'[Image 2](./images/cdCSj+-012340.jpg)',
'[Image 3](/images/test-4(2)))).jpeg)',
'[Image 4](./images/test-2211jd.webp)',
'[Image 5](/images/sa)mple.gif)',
]);
});
});
describe('extractPartsFromAsset', () => {
it('should return parts from asset - PNG', () => {
const result = extractPartsFromAsset('[Image 1](./images/sample-4(2).png)');
expect(result).toEqual({
label: 'Image 1',
path: '/images/sample-4(2)',
ext: '.png',
});
});
it('should return parts from asset - JPG', () => {
const result = extractPartsFromAsset(
'[Image 2](./images/cdCSj+-012340.jpg)',
);
expect(result).toEqual({
label: 'Image 2',
path: '/images/cdCSj+-012340',
ext: '.jpg',
});
});
it('should return parts from asset - JPEG', () => {
const result = extractPartsFromAsset(
'[Image 2](/images/test-4(2)))).jpeg)',
);
expect(result).toEqual({
label: 'Image 2',
path: '/images/test-4(2))))',
ext: '.jpeg',
});
});
it('should return parts from asset - WEBP', () => {
const result = extractPartsFromAsset('[Image 2](/images/test-2211jd.webp)');
expect(result).toEqual({
label: 'Image 2',
path: '/images/test-2211jd',
ext: '.webp',
});
});
it('should return parts from asset - GIF', () => {
const result = extractPartsFromAsset('[Image 2](/images/test-4(2)))).gif)');
expect(result).toEqual({
label: 'Image 2',
path: '/images/test-4(2))))',
ext: '.gif',
});
});
});
describe('replaceReadme', () => {
it('should return mime type', async () => {
const readme = `
## Images
![Image 1](./images/sample-4(2).png)
![Image 2](./images/cdCSj+-012340.jpg)
![Image 3](/images/test-4(2)))).jpeg)
![Image 4](./images/test-2211jd.webp)
![Image 5](/images/sa)mple.gif)
`;
const reader: UrlReader = {
read: url => new Promise<Buffer>(resolve => resolve(Buffer.from(url))),
readTree: jest.fn(),
search: jest.fn(),
readUrl: jest.fn(),
};
const result = await replaceReadme(
reader,
'host',
'org',
'project',
'repo',
readme,
);
const expected = `
## Images
![Image 1](data:image/png;base64,aHR0cHM6Ly9ob3N0L29yZy9wcm9qZWN0L19naXQvcmVwbz9wYXRoPSUyRmltYWdlcyUyRnNhbXBsZS00KDIpLnBuZw==)
![Image 2](data:image/jpeg;base64,aHR0cHM6Ly9ob3N0L29yZy9wcm9qZWN0L19naXQvcmVwbz9wYXRoPSUyRmltYWdlcyUyRmNkQ1NqJTJCLTAxMjM0MC5qcGc=)
![Image 3](data:image/jpeg;base64,aHR0cHM6Ly9ob3N0L29yZy9wcm9qZWN0L19naXQvcmVwbz9wYXRoPSUyRmltYWdlcyUyRnRlc3QtNCgyKSkpKS5qcGVn)
![Image 4](data:image/webp;base64,aHR0cHM6Ly9ob3N0L29yZy9wcm9qZWN0L19naXQvcmVwbz9wYXRoPSUyRmltYWdlcyUyRnRlc3QtMjIxMWpkLndlYnA=)
![Image 5](data:image/gif;base64,aHR0cHM6Ly9ob3N0L29yZy9wcm9qZWN0L19naXQvcmVwbz9wYXRoPSUyRmltYWdlcyUyRnNhKW1wbGUuZ2lm)
`;
expect(expected).toBe(result);
});
});
@@ -30,9 +30,11 @@ import {
GitRepository,
IdentityRefWithVote,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import mime from 'mime-types';
import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces';
import { UrlReader } from '@backstage/backend-common';
export function convertDashboardPullRequest(
pullRequest: GitPullRequest,
@@ -205,6 +207,48 @@ export function convertPolicy(
};
}
export async function replaceReadme(
urlReader: UrlReader,
host: string,
org: string,
project: string,
repo: string,
readmeContent: string,
) {
const filesPath = extractAssets(readmeContent);
if (!filesPath) return readmeContent;
return await filesPath.reduce(
async (promise: Promise<string>, filePath: string) =>
promise.then(async content => {
const { label, path, ext } = extractPartsFromAsset(filePath);
const data = mime.lookup(ext);
const url = buildEncodedUrl(host, org, project, repo, path + ext);
const buffer = await urlReader.read(url);
const file = await buffer.toString('base64');
return content.replace(
filePath,
`[${label}](data:${data};base64,${file})`,
);
}),
Promise.resolve(readmeContent),
);
}
export function buildEncodedUrl(
host: string,
org: string,
project: string,
repo: string,
path: string,
): string {
const encodedHost = encodeURIComponent(host);
const encodedOrg = encodeURIComponent(org);
const encodedProject = encodeURIComponent(project);
const encodedRepo = encodeURIComponent(repo);
const encodedPath = encodeURIComponent(path);
return `https://${encodedHost}/${encodedOrg}/${encodedProject}/_git/${encodedRepo}?path=${encodedPath}`;
}
function convertReviewer(
identityRef?: IdentityRefWithVote,
): Reviewer | undefined {
@@ -263,3 +307,24 @@ function convertCreatedBy(identityRef?: IdentityRef): CreatedBy | undefined {
function hasAutoComplete(pullRequest: GitPullRequest): boolean {
return pullRequest.isDraft !== true && !!pullRequest.completionOptions;
}
export function extractAssets(content: string) {
const regExp =
/\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.png|\.jpg|\.jpeg|\.gif|\.webp)(.*)\)/gim;
return content.match(regExp);
}
export function extractPartsFromAsset(content: string): {
label: string;
path: string;
ext: string;
} {
const regExp =
/\[(.*?)\]\((?!https?:\/\/)(.*?)(\.png|\.jpg|\.jpeg|\.gif|\.webp)(.*)\)/;
const [_, label, path, ext] = regExp.exec(content) || [];
return {
ext,
label,
path: path.startsWith('.') ? path.substring(1, path.length) : path,
};
}
+16
View File
@@ -197,6 +197,22 @@ export enum PullRequestVoteStatus {
WaitingForAuthor = -5,
}
// @public (undocumented)
export interface Readme {
// (undocumented)
content: string;
// (undocumented)
url: string;
}
// @public (undocumented)
export interface ReadmeConfig {
// (undocumented)
project: string;
// (undocumented)
repo: string;
}
// @public (undocumented)
export type RepoBuild = {
id?: number;
+12
View File
@@ -206,6 +206,18 @@ export interface Team {
members?: string[];
}
/** @public */
export interface ReadmeConfig {
project: string;
repo: string;
}
/** @public */
export interface Readme {
url: string;
content: string;
}
/** @public */
export interface TeamMember {
id?: string;
+52 -1
View File
@@ -22,6 +22,12 @@ Lists all Git Tags for a given repository
![Azure Repos Git Tags Example](./docs/azure-devops-git-tags.png)
### Azure Readme
Readme for a given repository
![Azure Readme Example](./docs/azure-devops-readme.png)
## Setup
The following sections will help you get the Azure DevOps plugin setup and running
@@ -70,7 +76,7 @@ In this case `<project-name>` will be the name of your Team Project and `<build-
To get the Azure Pipelines component working you'll need to do the following two steps:
1. First we need to add the @backstage/plugin-azure-devops package to your frontend app:
1. First we need to add the `@backstage/plugin-azure-devops` package to your frontend app:
```bash
# From your Backstage root directory
@@ -197,6 +203,51 @@ To get the Git Tags component working you'll need to do the following two steps:
- You'll need to add the `EntityLayout.Route` above from step 2 to all the entity sections you want to see Git Tags in. For example if you wanted to see Git Tags when looking at Website entities then you would need to add this to the `websiteEntityPage` section.
- The `if` prop is optional on the `EntityLayout.Route`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation
### Git README
To get the README component working you'll need to do the following two steps:
1. First we need to add the @backstage/plugin-azure-devops package to your frontend app:
```bash
# From your Backstage root directory
yarn add --cwd packages/app @backstage/plugin-azure-devops
```
2. Second we need to add the `EntityAzureReadmeCard` extension to the entity page in your app:
```tsx
// In packages/app/src/components/catalog/EntityPage.tsx
import {
EntityAzureReadmeCard,
isAzureDevOpsAvailable,
} from '@backstage/plugin-azure-devops';
// As it is a card, you can customize it the way you prefer
// For example in the Service section
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
<EntitySwitch>
<EntitySwitch.Case if={isAzureDevOpsAvailable}>
<Grid item md={6}>
...
</Grid>
<Grid item md={6}>
<EntityAzureReadmeCard maxHeight={350} />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
</Grid>
);
```
**Notes:**
- You'll need to add the `EntitySwitch.Case` above from step 2 to all the entity sections you want to see Readme in. For example if you wanted to see Readme when looking at Website entities then you would need to add this to the `websiteEntityPage` section.
- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation
- The `maxHeight` property on the `EntityAzureReadmeCard` will set the maximum screen size you would like to see, if not set it will default to 100%
## Limitations
- Currently multiple organizations are not supported
+11
View File
@@ -16,6 +16,8 @@ import { GitTag } from '@backstage/plugin-azure-devops-common';
import { IdentityApi } from '@backstage/core-plugin-api';
import { PullRequest } from '@backstage/plugin-azure-devops-common';
import { PullRequestOptions } from '@backstage/plugin-azure-devops-common';
import { Readme } from '@backstage/plugin-azure-devops-common';
import { ReadmeConfig } from '@backstage/plugin-azure-devops-common';
import { RepoBuild } from '@backstage/plugin-azure-devops-common';
import { RepoBuildOptions } from '@backstage/plugin-azure-devops-common';
import { SvgIconProps } from '@material-ui/core';
@@ -91,6 +93,8 @@ export interface AzureDevOpsApi {
items: PullRequest[];
}>;
// (undocumented)
getReadme(opts: ReadmeConfig): Promise<Readme>;
// (undocumented)
getRepoBuilds(
projectName: string,
repoName: string,
@@ -142,6 +146,8 @@ export class AzureDevOpsClient implements AzureDevOpsApi {
items: PullRequest[];
}>;
// (undocumented)
getReadme(opts: ReadmeConfig): Promise<Readme>;
// (undocumented)
getRepoBuilds(
projectName: string,
repoName: string,
@@ -229,6 +235,11 @@ export const EntityAzurePullRequestsContent: (props: {
defaultLimit?: number | undefined;
}) => JSX.Element;
// @public (undocumented)
export const EntityAzureReadmeCard: (props: {
maxHeight?: number | undefined;
}) => JSX.Element;
// @public (undocumented)
export type Filter =
| AssignedToUserFilter
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -21,6 +21,8 @@ import {
GitTag,
PullRequest,
PullRequestOptions,
Readme,
ReadmeConfig,
RepoBuild,
RepoBuildOptions,
Team,
@@ -66,4 +68,6 @@ export interface AzureDevOpsApi {
definitionName?: string,
options?: BuildRunOptions,
): Promise<{ items: BuildRun[] }>;
getReadme(opts: ReadmeConfig): Promise<Readme>;
}
@@ -21,6 +21,8 @@ import {
GitTag,
PullRequest,
PullRequestOptions,
Readme,
ReadmeConfig,
RepoBuild,
RepoBuildOptions,
Team,
@@ -130,6 +132,14 @@ export class AzureDevOpsClient implements AzureDevOpsApi {
return { items };
}
public async getReadme(opts: ReadmeConfig): Promise<Readme> {
return await this.get(
`readme/${encodeURIComponent(opts.project)}/${encodeURIComponent(
opts.repo,
)}`,
);
}
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,120 @@
/*
* 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 { Box, Button, makeStyles } from '@material-ui/core';
import {
InfoCard,
Progress,
MarkdownContent,
EmptyState,
ErrorPanel,
} 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 => ({
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;
};
type ErrorProps = {
error: Error;
};
function isNotFoundError(error: any): boolean {
return error?.response?.status === 404;
}
const ReadmeCardError = ({ error }: ErrorProps) => {
if (isNotFoundError(error))
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 <ErrorPanel title={error.message} error={error} />;
};
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 <ReadmeCardError error={error} />;
}
return (
<InfoCard
title="Readme"
deepLink={{
link: value!.url,
title: 'Readme',
}}
>
<Box className={classes.readMe} sx={{ maxHeight: props.maxHeight }}>
<MarkdownContent content={value?.content ?? ''} />
</Box>
</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,
EntityAzureReadmeCard,
isAzureDevOpsAvailable,
isAzurePipelinesAvailable,
AzurePullRequestsPage,
+11
View File
@@ -29,6 +29,7 @@ import {
createApiFactory,
createPlugin,
createRoutableExtension,
createComponentExtension,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
@@ -107,3 +108,13 @@ export const EntityAzurePullRequestsContent = azureDevOpsPlugin.provide(
mountPoint: azurePullRequestsEntityContentRouteRef,
}),
);
/** @public */
export const EntityAzureReadmeCard = azureDevOpsPlugin.provide(
createComponentExtension({
name: 'EntityAzureReadmeCard',
component: {
lazy: () => import('./components/ReadmeCard').then(m => m.ReadmeCard),
},
}),
);