Merge pull request #22791 from DavidRobertsOrbis/azd-readme-location

azure-devops plugin: Ability to fetch the README file from a different AZD path
This commit is contained in:
Fredrik Adelöw
2024-02-21 09:00:19 +01:00
committed by GitHub
15 changed files with 235 additions and 8 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/plugin-azure-devops-backend': minor
'@backstage/plugin-azure-devops-common': minor
'@backstage/plugin-azure-devops': minor
---
Ability to fetch the README file from a different Azure DevOps path.
Defaults to the current, Azure DevOps default behaviour (`README.md` in the root of the git repo); to use a different path, add the annotation `dev.azure.com/readme-path`
Example:
```yaml
dev.azure.com/readme-path: /my-path/README.md
```
@@ -124,6 +124,7 @@ export class AzureDevOpsApi {
org: string,
project: string,
repo: string,
path: string,
): Promise<{
url: string;
content: string;
@@ -32,6 +32,7 @@
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-azure-devops-common": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
@@ -527,11 +527,12 @@ export class AzureDevOpsApi {
org: string,
project: string,
repo: string,
path: string,
): Promise<{
url: string;
content: string;
}> {
const url = buildEncodedUrl(host, org, project, repo, 'README.md');
const url = buildEncodedUrl(host, org, project, repo, path);
const response = await this.urlReader.readUrl(url);
const buffer = await response.buffer();
const content = await replaceReadme(
@@ -482,7 +482,7 @@ describe('createRouter', () => {
});
describe('GET /readme/:projectName/:repoName', () => {
it('fetches readme file', async () => {
it('fetches default default readme file', async () => {
const content = getReadmeMock();
const url = `https://host.com/myOrg/myProject/_git/myRepo?path=README.md`;
@@ -491,14 +491,13 @@ describe('createRouter', () => {
url,
});
const response = await request(app).get(
'/readme/myProject/myRepo?path=README.md',
);
const response = await request(app).get('/readme/myProject/myRepo');
expect(azureDevOpsApi.getReadme).toHaveBeenCalledWith(
'host.com',
'myOrg',
'myProject',
'myRepo',
'README.md',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
@@ -507,6 +506,80 @@ describe('createRouter', () => {
});
});
});
describe('GET /readme/:projectName/:repoName with readme filename', () => {
it('fetches specified readme file', async () => {
const content = getReadmeMock();
const url = `https://host.com/myOrg/myProject/_git/myRepo?path=README_NOT_DEFAULT.md`;
azureDevOpsApi.getReadme.mockResolvedValueOnce({
content,
url,
});
const response = await request(app).get(
'/readme/myProject/myRepo?path=README_NOT_DEFAULT.md',
);
expect(azureDevOpsApi.getReadme).toHaveBeenCalledWith(
'host.com',
'myOrg',
'myProject',
'myRepo',
'README_NOT_DEFAULT.md',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
content,
url,
});
});
});
describe('GET /readme/:projectName/:repoName with readme path', () => {
it('fetches specified readme file from subfolder', async () => {
const content = getReadmeMock();
const url = `https://host.com/myOrg/myProject/_git/myRepo?path=/my-path/README.md`;
azureDevOpsApi.getReadme.mockResolvedValueOnce({
content,
url,
});
const response = await request(app).get(
'/readme/myProject/myRepo?path=/my-path/README.md',
);
expect(azureDevOpsApi.getReadme).toHaveBeenCalledWith(
'host.com',
'myOrg',
'myProject',
'myRepo',
'/my-path/README.md',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
content,
url,
});
});
});
describe('GET /readme/:projectName/:repoName with a bad readme path (multiple values)', () => {
it('throws InputError', async () => {
const response = await request(app).get(
'/readme/myProject/myRepo?path=1&path=2',
);
expect(azureDevOpsApi.getReadme).not.toHaveBeenCalled();
expect(response.status).toEqual(400);
});
});
describe('GET /readme/:projectName/:repoName with a bad readme path (empty string)', () => {
it('throws InputError', async () => {
const response = await request(app).get('/readme/myProject/myRepo?path=');
expect(azureDevOpsApi.getReadme).not.toHaveBeenCalled();
expect(response.status).toEqual(400);
});
});
});
function getReadmeMock() {
@@ -26,6 +26,7 @@ import { Logger } from 'winston';
import { PullRequestsDashboardProvider } from '../api/PullRequestsDashboardProvider';
import Router from 'express-promise-router';
import { errorHandler, UrlReader } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import express from 'express';
const DEFAULT_TOP = 10;
@@ -216,12 +217,28 @@ export async function createRouter(
req.query.host?.toString() ?? config.getString('azureDevOps.host');
const org =
req.query.org?.toString() ?? config.getString('azureDevOps.organization');
let path = req.query.path;
if (path === undefined) {
// if the annotation is missing, default to the previous behaviour (look for README.md in the root of the repo)
path = 'README.md';
}
if (typeof path !== 'string') {
throw new InputError('Invalid path param');
}
if (path === '') {
throw new InputError('If present, the path param should not be empty');
}
const { projectName, repoName } = req.params;
const readme = await azureDevOpsApi.getReadme(
host,
org,
projectName,
repoName,
path,
);
res.status(200).json(readme);
});
@@ -16,6 +16,9 @@ export const AZURE_DEVOPS_HOST_ORG_ANNOTATION = 'dev.azure.com/host-org';
// @public (undocumented)
export const AZURE_DEVOPS_PROJECT_ANNOTATION = 'dev.azure.com/project';
// @public (undocumented)
export const AZURE_DEVOPS_README_ANNOTATION = 'dev.azure.com/readme-path';
// @public (undocumented)
export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo';
@@ -228,6 +231,8 @@ export interface ReadmeConfig {
// (undocumented)
org?: string;
// (undocumented)
path?: string;
// (undocumented)
project: string;
// (undocumented)
repo: string;
@@ -22,6 +22,8 @@ export const AZURE_DEVOPS_HOST_ORG_ANNOTATION = 'dev.azure.com/host-org';
/** @public */
export const AZURE_DEVOPS_PROJECT_ANNOTATION = 'dev.azure.com/project';
/** @public */
export const AZURE_DEVOPS_README_ANNOTATION = 'dev.azure.com/readme-path';
/** @public */
export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo';
/** @public */
export const AZURE_DEVOPS_DEFAULT_TOP: number = 10;
+1
View File
@@ -212,6 +212,7 @@ export interface ReadmeConfig {
repo: string;
host?: string;
org?: string;
path?: string;
}
/** @public */
+7 -1
View File
@@ -63,13 +63,19 @@ spec:
#### Mono repos
If you have multiple entities within a single repo, you will need to specify which pipelines belong to each entity.
If you have multiple entities within a single repo, you will need to specify which pipelines belong to each entity, like this:
```yaml
dev.azure.com/project-repo: <my-project>/<my-repo>
dev.azure.com/build-definition: <build-definition-name>
```
Then to display the `README` file that belongs to each entity you would do this:
```yaml
dev.azure.com/readme-path: /<path-to>/<my-readme-file>.md
```
#### Pipeline in different project to repo
If your pipeline is in a different project to the source code, you will need to specify this in the project annotation.
@@ -189,6 +189,9 @@ export class AzureDevOpsClient implements AzureDevOpsApi {
if (opts.org) {
queryString.append('org', opts.org);
}
if (opts.path) {
queryString.append('path', opts.path);
}
return await this.get(
`readme/${encodeURIComponent(opts.project)}/${encodeURIComponent(
opts.repo,
+9 -2
View File
@@ -30,8 +30,15 @@ export function useReadme(entity: Entity): {
const api = useApi(azureDevOpsApiRef);
const { value, loading, error } = useAsync(() => {
const { project, repo, host, org } = getAnnotationValuesFromEntity(entity);
return api.getReadme({ project, repo: repo as string, host, org });
const { project, repo, host, org, readmePath } =
getAnnotationValuesFromEntity(entity);
return api.getReadme({
project,
repo: repo as string,
host,
org,
path: readmePath,
});
}, [api]);
return {
@@ -52,6 +52,7 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: 'repoName',
definition: undefined,
readmePath: undefined,
host: undefined,
org: undefined,
});
@@ -149,6 +150,7 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: undefined,
definition: 'buildDefinitionName',
readmePath: undefined,
host: undefined,
org: undefined,
});
@@ -220,6 +222,7 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: 'repoName',
definition: undefined,
readmePath: undefined,
host: 'hostName',
org: 'organizationName',
});
@@ -246,6 +249,7 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: undefined,
definition: 'buildDefinitionName',
readmePath: undefined,
host: 'hostName',
org: 'organizationName',
});
@@ -344,6 +348,7 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: 'repoName',
definition: undefined,
readmePath: undefined,
host: 'company.com/tfs',
org: 'organizationName',
});
@@ -417,6 +422,7 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: 'repoName',
definition: 'buildDefinitionName',
readmePath: undefined,
host: undefined,
org: undefined,
});
@@ -443,6 +449,87 @@ describe('getAnnotationValuesFromEntity', () => {
project: 'projectName',
repo: undefined,
definition: 'buildDefinitionName',
readmePath: undefined,
host: undefined,
org: undefined,
});
});
});
describe('definition, project and readme', () => {
it('returns with the readme path', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project': 'projectName',
'dev.azure.com/build-definition': 'buildDefinitionName',
'dev.azure.com/readme-path': 'readme/path.md',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: undefined,
definition: 'buildDefinitionName',
readmePath: 'readme/path.md',
host: undefined,
org: undefined,
});
});
});
describe('definition, projectRepo and readme', () => {
it('returns with the readme path', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
'dev.azure.com/build-definition': 'buildDefinitionName',
'dev.azure.com/readme-path': 'readme/path.md',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: 'repoName',
definition: 'buildDefinitionName',
readmePath: 'readme/path.md',
host: undefined,
org: undefined,
});
});
});
describe('projectRepo and readme', () => {
it('returns with the readme path', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
'dev.azure.com/readme-path': 'readme/path.md',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: 'repoName',
definition: undefined,
readmePath: 'readme/path.md',
host: undefined,
org: undefined,
});
@@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model';
import {
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
AZURE_DEVOPS_README_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
AZURE_DEVOPS_HOST_ORG_ANNOTATION,
} from '@backstage/plugin-azure-devops-common';
@@ -28,6 +29,7 @@ export function getAnnotationValuesFromEntity(entity: Entity): {
definition?: string;
host?: string;
org?: string;
readmePath?: string;
} {
const hostOrg = getHostOrg(entity.metadata.annotations);
const projectRepo = getProjectRepo(entity.metadata.annotations);
@@ -35,12 +37,15 @@ export function getAnnotationValuesFromEntity(entity: Entity): {
entity.metadata.annotations?.[AZURE_DEVOPS_PROJECT_ANNOTATION];
const definition =
entity.metadata.annotations?.[AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION];
const readmePath =
entity.metadata.annotations?.[AZURE_DEVOPS_README_ANNOTATION];
if (definition) {
if (project) {
return {
project,
definition,
readmePath: readmePath,
...hostOrg,
};
}
@@ -49,6 +54,7 @@ export function getAnnotationValuesFromEntity(entity: Entity): {
project: projectRepo.project,
repo: projectRepo.repo,
definition,
readmePath: readmePath,
...hostOrg,
};
}
@@ -60,6 +66,7 @@ export function getAnnotationValuesFromEntity(entity: Entity): {
return {
project: projectRepo.project,
repo: projectRepo.repo,
readmePath: readmePath,
...hostOrg,
};
}
+1
View File
@@ -4933,6 +4933,7 @@ __metadata:
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-azure-devops-common": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"