diff --git a/.changeset/shiny-lobsters-fix.md b/.changeset/shiny-lobsters-fix.md new file mode 100644 index 0000000000..99e255acbe --- /dev/null +++ b/.changeset/shiny-lobsters-fix.md @@ -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 { + 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} +``` diff --git a/.changeset/weak-yaks-learn.md b/.changeset/weak-yaks-learn.md new file mode 100644 index 0000000000..7a94e10d68 --- /dev/null +++ b/.changeset/weak-yaks-learn.md @@ -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 = ( + + + + + ... + + + + + + + + ); + ``` + +**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% diff --git a/packages/backend/src/plugins/azure-devops.ts b/packages/backend/src/plugins/azure-devops.ts index 67bba8ac85..ed5b1d0068 100644 --- a/packages/backend/src/plugins/azure-devops.ts +++ b/packages/backend/src/plugins/azure-devops.ts @@ -24,5 +24,6 @@ export default async function createPlugin( return createRouter({ logger: env.logger, config: env.config, + reader: env.reader, }); } diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 5918195827..900b3c3d12 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -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; // (undocumented) @@ -71,6 +72,16 @@ export class AzureDevOpsApi { options: PullRequestOptions, ): Promise; // (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) diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 467a3f7e5b..04bf4844c0 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -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", diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 3bffa925f5..2bf2988a98 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -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 { @@ -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 { diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index 3bf18de6a2..a77600feb0 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -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) + `; +} diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index 0cb504537c..b6b8a6fdea 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, 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 { - 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; } diff --git a/plugins/azure-devops-backend/src/service/standaloneServer.ts b/plugins/azure-devops-backend/src/service/standaloneServer.ts index 1d5889fcf1..e2f4aa4986 100644 --- a/plugins/azure-devops-backend/src/service/standaloneServer.ts +++ b/plugins/azure-devops-backend/src/service/standaloneServer.ts @@ -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) diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts index 8aed436710..f044a20968 100644 --- a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts @@ -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(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); + }); +}); 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..191486a925 100644 --- a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts @@ -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, 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, + }; +} diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index ca8206b508..ea9f7130c0 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -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; diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index d763d151ca..380cb3e585 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -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; diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index 68d2b57afc..5079fa4f16 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -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 `` will be the name of your Team Project and ` + + + + ... + + + + + + + + ); + ``` + +**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 diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index 3c01936390..d083d00dba 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -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; + // (undocumented) getRepoBuilds( projectName: string, repoName: string, @@ -142,6 +146,8 @@ export class AzureDevOpsClient implements AzureDevOpsApi { items: PullRequest[]; }>; // (undocumented) + getReadme(opts: ReadmeConfig): Promise; + // (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 diff --git a/plugins/azure-devops/docs/azure-devops-readme.png b/plugins/azure-devops/docs/azure-devops-readme.png new file mode 100644 index 0000000000..cac087f685 Binary files /dev/null and b/plugins/azure-devops/docs/azure-devops-readme.png differ diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index d90fd71b30..ce6a3cac39 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, @@ -66,4 +68,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 41d7126f40..68d8896538 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, @@ -130,6 +132,14 @@ export class AzureDevOpsClient implements AzureDevOpsApi { return { items }; } + public async getReadme(opts: ReadmeConfig): Promise { + return await this.get( + `readme/${encodeURIComponent(opts.project)}/${encodeURIComponent( + opts.repo, + )}`, + ); + } + 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..d06726e929 --- /dev/null +++ b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx @@ -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 ( + + Read more + + } + /> + ); + return ; +}; + +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 ; + } + + return ( + + + + + + ); +}; 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..9b92a0eaf1 --- /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..37e85b7422 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -19,6 +19,7 @@ export { EntityAzurePipelinesContent, EntityAzureGitTagsContent, EntityAzurePullRequestsContent, + EntityAzureReadmeCard, isAzureDevOpsAvailable, isAzurePipelinesAvailable, AzurePullRequestsPage, diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index a9753a26c1..4dd423f35a 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -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), + }, + }), +);