Merge pull request #13143 from alissonfabiano/af/add-readme-card-azure-devops
[Azure DevOps] add README card
This commit is contained in:
@@ -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}
|
||||
```
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
.png)
|
||||

|
||||
))).jpeg)
|
||||

|
||||
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
|
||||
.png)
|
||||

|
||||
))).jpeg)
|
||||

|
||||
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
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
`;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,12 @@ Lists all Git Tags for a given repository
|
||||
|
||||

|
||||
|
||||
### Azure Readme
|
||||
|
||||
Readme for a given repository
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -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';
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
EntityAzurePipelinesContent,
|
||||
EntityAzureGitTagsContent,
|
||||
EntityAzurePullRequestsContent,
|
||||
EntityAzureReadmeCard,
|
||||
isAzureDevOpsAvailable,
|
||||
isAzurePipelinesAvailable,
|
||||
AzurePullRequestsPage,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user