diff --git a/.changeset/weak-yaks-learn.md b/.changeset/weak-yaks-learn.md
index dfbc256af1..bf0e3eafd0 100644
--- a/.changeset/weak-yaks-learn.md
+++ b/.changeset/weak-yaks-learn.md
@@ -1,7 +1,76 @@
---
-'@backstage/plugin-azure-devops': patch
-'@backstage/plugin-azure-devops-backend': patch
-'@backstage/plugin-azure-devops-common': patch
+'@backstage/plugin-azure-devops': minor
+'@backstage/plugin-azure-devops-backend': minor
+'@backstage/plugin-azure-devops-common': minor
---
Added README card for Azure Devops
+
+**Before:**
+
+Remember to check if you have already provided these settings previously, as we will need them for the reame card to work.
+
+#### [Azure DevOps](https://github.com/backstage/backstage/blob/master/app-config.yaml#L48L51:~:text=azureDevOps%3A,%3A%20my%2Dcompany)
+
+```yaml
+# app-config.yaml
+azureDevOps:
+ host: dev.azure.com
+ token: my-token
+ organization: my-company
+```
+
+#### [Azure Integrations](https://github.com/backstage/backstage/blob/master/app-config.yaml#L48L51:~:text=azure%3A,%3A%20%24%7BAZURE_TOKEN%7D)
+
+```yaml
+# app-config.yaml
+integrations:
+ azure:
+ - host: dev.azure.com
+ token: ${AZURE_TOKEN}
+```
+
+**After:**
+
+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/package.json b/plugins/azure-devops-backend/package.json
index d960c1e311..5c1ecb0901 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-next.0",
diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts
index f737eac28b..92bf90d240 100644
--- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts
+++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts
@@ -43,6 +43,7 @@ import {
convertPolicy,
getArtifactId,
replaceReadme,
+ buildEncodedUrl,
} from '../utils';
import { TeamMember as AdoTeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
@@ -399,32 +400,24 @@ export class AzureDevOpsApi {
public async getReadme(
host: string,
- organization: string,
- projectName: string,
- repoName: string,
+ org: string,
+ project: string,
+ repo: string,
): Promise<{
url: string;
content: string;
}> {
- const getFileContent = async (
- path: string,
- encoding?: BufferEncoding,
- ): Promise<{
- url: string;
- content: string;
- }> => {
- const url = `https://${host}/${organization}/${projectName}/_git/${repoName}?path=${path}`;
- const response = await this.urlReader.read(url);
- return {
- url,
- content: Buffer.from(response).toString(encoding),
- };
- };
- const { url, content } = await getFileContent('README.md');
- return {
- url,
- content: await replaceReadme(content, getFileContent),
- };
+ 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 };
}
}
diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts
index 717a716b77..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', () => {
@@ -55,18 +55,28 @@ describe('createRouter', () => {
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);
});
diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts
index 8ea9b20e2c..d371e79e7e 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, UrlReaders } from '@backstage/backend-common';
+import { errorHandler, UrlReader } from '@backstage/backend-common';
import express from 'express';
const DEFAULT_TOP = 10;
@@ -35,18 +35,18 @@ export interface RouterOptions {
azureDevOpsApi?: AzureDevOpsApi;
logger: Logger;
config: Config;
+ reader: UrlReader;
}
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');
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);
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 c9bde296f6..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
@@ -25,12 +25,11 @@ import {
extractPartsFromAsset,
getArtifactId,
getAvatarUrl,
- getMimeByExtension,
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', () => {
@@ -236,16 +235,6 @@ describe('extractPartsFromAsset', () => {
});
});
-describe('getMimeByExtension', () => {
- it('should return mime type', () => {
- expect(getMimeByExtension('.png')).toBe('image/png');
- expect(getMimeByExtension('.jpg')).toBe('image/jpeg');
- expect(getMimeByExtension('.jpeg')).toBe('image/jpeg');
- expect(getMimeByExtension('.webp')).toBe('image/webp');
- expect(getMimeByExtension('.gif')).toBe('image/gif');
- });
-});
-
describe('replaceReadme', () => {
it('should return mime type', async () => {
const readme = `
@@ -257,31 +246,29 @@ describe('replaceReadme', () => {
mple.gif)
`;
- async function mockFileContent(
- path: string,
- encoding?: BufferEncoding,
- ): Promise<{
- url: string;
- content: string;
- }> {
- expect(encoding).toBe('base64');
- return new Promise(resolve =>
- resolve({
- url: '',
- content: Buffer.from(path).toString(encoding),
- }),
- );
- }
+ 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(readme, mockFileContent);
+ const result = await replaceReadme(
+ reader,
+ 'host',
+ 'org',
+ 'project',
+ 'repo',
+ readme,
+ );
const expected = `
## Images
- 
- 
- 
- 
- 
+ 
+ 
+ 
+ 
+ 
`;
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 ab5a4113d2..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,
@@ -206,31 +208,47 @@ export function convertPolicy(
}
export async function replaceReadme(
- readme: string,
- getFileContent: (
- path: string,
- encoding?: BufferEncoding,
- ) => Promise<{
- url: string;
- content: string;
- }>,
+ urlReader: UrlReader,
+ host: string,
+ org: string,
+ project: string,
+ repo: string,
+ readmeContent: string,
) {
- const filesPath = extractAssets(readme);
+ 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 mime = getMimeByExtension(ext);
- const file = await getFileContent(path + ext, 'base64');
+ 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:${mime};base64,${file.content})`,
+ `[${label}](data:${data};base64,${file})`,
);
}),
- Promise.resolve(readme),
+ 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 {
@@ -293,7 +311,7 @@ function hasAutoComplete(pullRequest: GitPullRequest): boolean {
export function extractAssets(content: string) {
const regExp =
/\[([^\[\]]*)\]\((?!https?:\/\/)(.*?)(\.png|\.jpg|\.jpeg|\.gif|\.webp)(.*)\)/gim;
- return content.match(regExp) || [];
+ return content.match(regExp);
}
export function extractPartsFromAsset(content: string): {
@@ -310,15 +328,3 @@ export function extractPartsFromAsset(content: string): {
path: path.startsWith('.') ? path.substring(1, path.length) : path,
};
}
-
-export function getMimeByExtension(ext: string): string {
- return (
- {
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.gif': 'image/gif',
- '.webp': 'image/webp',
- }[ext] || ''
- );
-}
diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts
index 1959d0389a..da6a3cda14 100644
--- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts
+++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts
@@ -67,5 +67,5 @@ export interface AzureDevOpsApi {
options?: BuildRunOptions,
): Promise<{ items: BuildRun[] }>;
- getReadme(opts: ReadmeConfig): Promise;
+ getReadme(opts: ReadmeConfig): Promise;
}
diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts
index f9384a0549..9797f4ff26 100644
--- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts
+++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts
@@ -32,10 +32,6 @@ 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;
@@ -136,22 +132,13 @@ export class AzureDevOpsClient implements AzureDevOpsApi {
return { items };
}
- public getReadme = async (
- opts: ReadmeConfig,
- ): Promise => {
- try {
- return await this.get(
- `readme/${encodeURIComponent(opts.project)}/${encodeURIComponent(
- opts.repo,
- )}`,
- );
- } catch (e) {
- if (isNotFoundError(e)) {
- return undefined;
- }
- return e;
- }
- };
+ 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')}/`;
diff --git a/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx
index 29031514e8..d06726e929 100644
--- a/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx
+++ b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx
@@ -14,13 +14,13 @@
* limitations under the License.
*/
-import { Button, makeStyles } from '@material-ui/core';
-import { Alert } from '@material-ui/lab';
+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';
@@ -30,15 +30,6 @@ 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),
@@ -63,6 +54,35 @@ 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);
@@ -81,52 +101,20 @@ export const ReadmeCard = (props: Props) => {
if (loading) {
return ;
} else if (error) {
- return (
-
- {error.message}
-
- );
+ return ;
}
- if (!value)
- return (
-
- Read more
-
- }
- />
- );
-
return (
{
- e.preventDefault();
- window.open(value?.url);
- },
}}
>
-
+
-
+
);
};
diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts
index b9fb95d9aa..c6d868717b 100644
--- a/plugins/azure-devops/src/plugin.ts
+++ b/plugins/azure-devops/src/plugin.ts
@@ -24,12 +24,12 @@ import {
azurePullRequestDashboardRouteRef,
azureGitTagsEntityContentRouteRef,
azurePullRequestsEntityContentRouteRef,
- azureReadmeEntityCardRouteRef,
} from './routes';
import {
createApiFactory,
createPlugin,
createRoutableExtension,
+ createComponentExtension,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
@@ -103,9 +103,10 @@ export const EntityAzurePullRequestsContent = azureDevOpsPlugin.provide(
);
export const EntityAzureReadmeCard = azureDevOpsPlugin.provide(
- createRoutableExtension({
+ createComponentExtension({
name: 'EntityAzureReadmeCard',
- component: () => import('./components/ReadmeCard').then(m => m.ReadmeCard),
- mountPoint: azureReadmeEntityCardRouteRef,
+ component: {
+ lazy: () => import('./components/ReadmeCard').then(m => m.ReadmeCard),
+ },
}),
);
diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts
index 3efce043a8..4ed35bbe12 100644
--- a/plugins/azure-devops/src/routes.ts
+++ b/plugins/azure-devops/src/routes.ts
@@ -31,7 +31,3 @@ export const azureGitTagsEntityContentRouteRef = createRouteRef({
export const azurePullRequestsEntityContentRouteRef = createRouteRef({
id: 'azure-pull-requests-entity-content',
});
-
-export const azureReadmeEntityCardRouteRef = createRouteRef({
- id: 'azure-readme-entity-card',
-});