From 2cd14f4ac2ce4d0d4ed58a432e1a1da34450aad7 Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:12:01 -0400 Subject: [PATCH 1/7] feat(azure-devops): scaffold readme path auto-detection fallback (#9188) Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- .../src/utils/getAnnotationValuesFromEntity.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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..3dbfea52d94 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,20 @@ 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; + + // TODO(#9188): parse URL, extract path param, replace catalog-info.yaml + // with README.md — full implementation in Phase III + return undefined; + })(); if (definition) { if (project) { From 7a82de55c92e19199b7404108de4a0d5e9301e1b Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:16:32 -0400 Subject: [PATCH 2/7] chore: add changeset for readme auto-detection feature (#9188) Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- workspaces/azure-devops/.changeset/rich-comics-complain.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 workspaces/azure-devops/.changeset/rich-comics-complain.md 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 From 25856920774e0d31b4d248942e2a37c9a7833043 Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:50:40 -0400 Subject: [PATCH 3/7] feat(azure-devops): implement readme path auto-detection from source-location When dev.azure.com/readme-path annotation is absent, derive the README path from backstage.io/source-location by extracting the Azure DevOps path query parameter and resolving README.md in the same directory as the catalog file. Non-Azure URLs and malformed values fall back gracefully to undefined. Closes #9188 Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- .../utils/getAnnotationValuesFromEntity.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) 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 3dbfea52d94..dfab94ddb1e 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 @@ -48,9 +48,36 @@ export function getAnnotationValuesFromEntity(entity: Entity): { entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION]; if (!sourceLocation) return undefined; - // TODO(#9188): parse URL, extract path param, replace catalog-info.yaml - // with README.md — full implementation in Phase III - 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.includes('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('.') + ? cleanPath.slice(0, lastSlash) + : cleanPath; + + return `${dir}/README.md`; + } catch { + // Malformed URL — fail gracefully + return undefined; + } })(); if (definition) { From 13edbdea6cb0aa74a74413be7faa63e3070f7998 Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:09:11 -0400 Subject: [PATCH 4/7] test(azure-devops): add tests for readme path auto-detection (#9188) Covers 8 new cases: explicit annotation takes precedence, catalog-info path derivation, directory paths, trailing slashes, absent annotations, non-Azure URLs, missing path param, and malformed URL graceful failure. 29/29 tests passing with zero regressions. Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- .../getAnnotationValuesFromEntity.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) 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(); + }); + }); }); From 154cde897b9ec7c336bb19abc9f1af64c1519826 Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:20:26 -0400 Subject: [PATCH 5/7] docs(azure-devops): document README auto-detection from source-location Adds documentation for the automatic README path detection introduced in #9188. When dev.azure.com/readme-path is absent the plugin falls back to backstage.io/source-location to derive the README path. Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- .../azure-devops/plugins/azure-devops/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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. From d94505ed671fcb95221f2a683c34390c616dcab3 Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:31:17 -0400 Subject: [PATCH 6/7] chore: add changeset for azure-devops plugin docs update (#9188) Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- workspaces/azure-devops/.changeset/shy-bulldogs-grow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 workspaces/azure-devops/.changeset/shy-bulldogs-grow.md 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 From fd3918c461fbd8f49371bb4848e4904c98785050 Mon Sep 17 00:00:00 2001 From: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:54:26 -0400 Subject: [PATCH 7/7] fix(azure-devops): address Copilot review comments on readme auto-detection - Use exact hostname match (=== dev.azure.com) instead of includes() to prevent matching subdomains like dev.azure.com.evil.example - Guard against lastIndexOf returning -1 when path has no slash, preventing incorrect slice(-1) behavior Signed-off-by: CyberCoder-IITM <127583612+CyberCoder-IITM@users.noreply.github.com> --- .../src/utils/getAnnotationValuesFromEntity.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 dfab94ddb1e..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 @@ -57,7 +57,7 @@ export function getAnnotationValuesFromEntity(entity: Entity): { const parsed = new URL(rawUrl); // Only derive paths for Azure DevOps URLs - if (!parsed.hostname.includes('dev.azure.com')) return undefined; + 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'); @@ -70,7 +70,9 @@ export function getAnnotationValuesFromEntity(entity: Entity): { // If the last segment looks like a file (has a dot), use its parent dir const dir = lastSegment.includes('.') - ? cleanPath.slice(0, lastSlash) + ? lastSlash >= 0 + ? cleanPath.slice(0, lastSlash) + : '' : cleanPath; return `${dir}/README.md`;