diff --git a/.changeset/itchy-news-drive.md b/.changeset/itchy-news-drive.md new file mode 100644 index 0000000000..97cca5b155 --- /dev/null +++ b/.changeset/itchy-news-drive.md @@ -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 +``` diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 6dead7f11b..d98d178cff 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -124,6 +124,7 @@ export class AzureDevOpsApi { org: string, project: string, repo: string, + path: string, ): Promise<{ url: string; content: string; diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 37a39e6ffe..2d5300a62a 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -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:^", diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index d50888006b..cc5cad92b7 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -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( diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts index 65cb27271e..c12b13a5aa 100644 --- a/plugins/azure-devops-backend/src/service/router.test.ts +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -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() { diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index 9a71c8f03d..bc13a4eee2 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -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); }); diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index 5bd1674fc8..e8343178e3 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -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; diff --git a/plugins/azure-devops-common/src/constants.ts b/plugins/azure-devops-common/src/constants.ts index 5cc6b54592..05eb430cc9 100644 --- a/plugins/azure-devops-common/src/constants.ts +++ b/plugins/azure-devops-common/src/constants.ts @@ -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; diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index afb6083867..0390bb1f6e 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -212,6 +212,7 @@ export interface ReadmeConfig { repo: string; host?: string; org?: string; + path?: string; } /** @public */ diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index f897588987..935e78c82c 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -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: / dev.azure.com/build-definition: ``` +Then to display the `README` file that belongs to each entity you would do this: + +```yaml +dev.azure.com/readme-path: //.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. diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index 76e38b064c..a10b10ff14 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -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, diff --git a/plugins/azure-devops/src/hooks/useReadme.ts b/plugins/azure-devops/src/hooks/useReadme.ts index 348b4219f0..9aa376e0bd 100644 --- a/plugins/azure-devops/src/hooks/useReadme.ts +++ b/plugins/azure-devops/src/hooks/useReadme.ts @@ -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 { diff --git a/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts index 9325a79039..e196e9dfc7 100644 --- a/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts +++ b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts @@ -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, }); diff --git a/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts index 7533c473ed..fb38a531e1 100644 --- a/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts +++ b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts @@ -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, }; } diff --git a/yarn.lock b/yarn.lock index 0a1a835df1..45de7b4d90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"