diff --git a/workspaces/azure-devops/.changeset/rich-comics-complain.md b/workspaces/azure-devops/.changeset/rich-comics-complain.md new file mode 100644 index 00000000000..333fd49c87e --- /dev/null +++ b/workspaces/azure-devops/.changeset/rich-comics-complain.md @@ -0,0 +1,5 @@ +--- +'@backstage-community/plugin-azure-devops-common': patch +--- + +Add auto-detection of README.md from backstage.io/source-location when dev.azure.com/readme-path annotation is absent diff --git a/workspaces/azure-devops/.changeset/shy-bulldogs-grow.md b/workspaces/azure-devops/.changeset/shy-bulldogs-grow.md new file mode 100644 index 00000000000..476546dda17 --- /dev/null +++ b/workspaces/azure-devops/.changeset/shy-bulldogs-grow.md @@ -0,0 +1,5 @@ +--- +'@backstage-community/plugin-azure-devops': patch +--- + +Add documentation for automatic README path detection from backstage.io/source-location diff --git a/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.test.ts b/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.test.ts index e196e9dfc77..0630ba66b1f 100644 --- a/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.test.ts +++ b/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.test.ts @@ -535,4 +535,124 @@ describe('getAnnotationValuesFromEntity', () => { }); }); }); + + describe('readme path auto-detection from source-location (#9188)', () => { + it('returns explicit readme-path annotation when both annotations are present', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'dev.azure.com/readme-path': '/explicit/README.md', + 'backstage.io/source-location': + 'url:https://dev.azure.com/org/proj/_git/repo?path=%2Fsvc%2Fcatalog-info.yaml', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBe('/explicit/README.md'); + }); + + it('derives readme path when source-location points to a catalog file', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'backstage.io/source-location': + 'url:https://dev.azure.com/org/proj/_git/repo?path=%2Fservices%2Fmy-svc%2Fcatalog-info.yaml', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBe('/services/my-svc/README.md'); + }); + + it('derives readme path when source-location points to a directory (no filename)', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'backstage.io/source-location': + 'url:https://dev.azure.com/org/proj/_git/repo?path=%2Fservices%2Fmy-svc', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBe('/services/my-svc/README.md'); + }); + + it('derives readme path when source-location path has a trailing slash', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'backstage.io/source-location': + 'url:https://dev.azure.com/org/proj/_git/repo?path=%2Fservices%2Fmy-svc%2F', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBe('/services/my-svc/README.md'); + }); + + it('returns undefined when neither readme-path nor source-location annotation is present', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBeUndefined(); + }); + + it('returns undefined when source-location is a non-Azure DevOps URL', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'backstage.io/source-location': + 'url:https://github.com/org/repo/blob/main/catalog-info.yaml', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBeUndefined(); + }); + + it('returns undefined when source-location Azure DevOps URL has no path query parameter', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'backstage.io/source-location': + 'url:https://dev.azure.com/org/proj/_git/repo', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBeUndefined(); + }); + + it('returns undefined gracefully when source-location is a malformed URL', () => { + const entity = { + metadata: { + annotations: { + 'dev.azure.com/project-repo': 'myproject/myrepo', + 'backstage.io/source-location': 'not-a-valid-url', + }, + }, + } as unknown as Entity; + + const result = getAnnotationValuesFromEntity(entity); + expect(result.readmePath).toBeUndefined(); + }); + }); }); diff --git a/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.ts b/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.ts index 836df2d9c85..f892c9ce994 100644 --- a/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.ts +++ b/workspaces/azure-devops/plugins/azure-devops-common/src/utils/getAnnotationValuesFromEntity.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model'; import { AZURE_DEVOPS_PROJECT_ANNOTATION, AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION, @@ -38,8 +38,49 @@ 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]; + const readmePath = (() => { + const explicit = + entity.metadata.annotations?.[AZURE_DEVOPS_README_ANNOTATION]; + if (explicit) return explicit; + + // Auto-detect README.md from backstage.io/source-location (#9188) + const sourceLocation = + entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION]; + if (!sourceLocation) return undefined; + + try { + // source-location values are prefixed with "url:" per Backstage convention + const rawUrl = sourceLocation.startsWith('url:') + ? sourceLocation.slice(4) + : sourceLocation; + + const parsed = new URL(rawUrl); + + // Only derive paths for Azure DevOps URLs + if (parsed.hostname !== 'dev.azure.com') return undefined; + + // "path" query param holds the repo-relative path (URL-decoded by the URL API) + const pathParam = parsed.searchParams.get('path'); + if (!pathParam) return undefined; + + // Strip trailing slash, then get the directory of the catalog file + const cleanPath = pathParam.replace(/\/$/, ''); + const lastSlash = cleanPath.lastIndexOf('/'); + const lastSegment = cleanPath.slice(lastSlash + 1); + + // If the last segment looks like a file (has a dot), use its parent dir + const dir = lastSegment.includes('.') + ? lastSlash >= 0 + ? cleanPath.slice(0, lastSlash) + : '' + : cleanPath; + + return `${dir}/README.md`; + } catch { + // Malformed URL — fail gracefully + return undefined; + } + })(); if (definition) { if (project) { diff --git a/workspaces/azure-devops/plugins/azure-devops/README.md b/workspaces/azure-devops/plugins/azure-devops/README.md index b75d42bb08c..f365b03395a 100644 --- a/workspaces/azure-devops/plugins/azure-devops/README.md +++ b/workspaces/azure-devops/plugins/azure-devops/README.md @@ -121,6 +121,18 @@ dev.azure.com/readme-path: //.md > Note: this annotation does not support relative paths as the API we use from Azure DevOps to power this feature does not support relative paths. If you use something like this `dev.azure.com/readme-path: ./docs/index.md` the frontend will throw an error with details about why. +If the `dev.azure.com/readme-path` annotation is not set, the plugin will automatically try to detect the README using the `backstage.io/source-location` annotation. When this annotation points to an Azure DevOps URL, the plugin resolves `README.md` in the same directory as the `catalog-info.yaml` file, so no extra configuration is needed. + +This is especially handy in monorepo setups. For example, if your entity has: + +```yaml +metadata: + annotations: + backstage.io/source-location: url:https://dev.azure.com/org/project/_git/repo?path=%2Fservices%2Fmy-service%2Fcatalog-info.yaml +``` + +The plugin will automatically render `/services/my-service/README.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.