Skip to content
5 changes: 5 additions & 0 deletions workspaces/azure-devops/.changeset/rich-comics-complain.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions workspaces/azure-devops/.changeset/shy-bulldogs-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage-community/plugin-azure-devops': patch
---

Add documentation for automatic README path detection from backstage.io/source-location
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,8 +38,47 @@ 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.includes('dev.azure.com')) return undefined;
Comment thread
CyberCoder-IITM marked this conversation as resolved.
Outdated

// "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`;
Comment thread
CyberCoder-IITM marked this conversation as resolved.
} catch {
// Malformed URL — fail gracefully
return undefined;
}
})();

if (definition) {
if (project) {
Expand Down
12 changes: 12 additions & 0 deletions workspaces/azure-devops/plugins/azure-devops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ dev.azure.com/readme-path: /<path-to>/<my-readme-file>.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.
Expand Down
Loading