diff --git a/packages/app/package.json b/packages/app/package.json
index d1892438d8..fe86077273 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -11,6 +11,7 @@
"@backstage/core-plugin-api": "^0.1.10",
"@backstage/integration-react": "^0.1.11",
"@backstage/plugin-api-docs": "^0.6.11",
+ "@backstage/plugin-azure-devops": "^0.1.0",
"@backstage/plugin-badges": "^0.2.12",
"@backstage/plugin-catalog": "^0.7.0",
"@backstage/plugin-catalog-graph": "^0.1.3",
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index 62fdcf4d3f..3fe027ddad 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -33,6 +33,10 @@ import {
EntityProvidedApisCard,
EntityProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
+import {
+ EntityAzurePipelinesContent,
+ isAzureDevOpsAvailable,
+} from '@backstage/plugin-azure-devops';
import { EntityBadgesDialog } from '@backstage/plugin-badges';
import {
EntityAboutCard,
@@ -183,6 +187,10 @@ export const cicdContent = (
+
+
+
+
{
- return await createRouter({ logger, config });
+ return createRouter({ logger, config });
}
diff --git a/plugins/azure-devops/.eslintrc.js b/plugins/azure-devops/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/azure-devops/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md
new file mode 100644
index 0000000000..edf26ecfb3
--- /dev/null
+++ b/plugins/azure-devops/README.md
@@ -0,0 +1,135 @@
+# Azure DevOps Plugin
+
+Website: [https://dev.azure.com/](https://dev.azure.com/)
+
+
+
+## Setup
+
+The following sections will help you get the Azure DevOps plugin setup and running
+
+### Configuration
+
+The Azure DevOps plugin requires the following YAML to be added to your app-config.yaml:
+
+```yaml
+azureDevOps:
+ host: dev.azure.com
+ token: ${AZURE_TOKEN}
+ organization: my-company
+```
+
+Configuration Details:
+
+- `host` and `token` can be the same as the ones used for the `integration` section
+- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build
+- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name
+
+### Backend
+
+Here's how to get the backend up and running:
+
+1. First we need to add the `@backstage/plugin-azure-devops-backend` package to your backend:
+
+ ```sh
+ # From the Backstage root directory
+ cd packages/backend
+ yarn add @backstage/plugin-azure-devops-backend
+ ```
+
+2. Then we will create a new file named `packages/backend/src/plugins/azure-devops.ts`, and add the
+ following to it:
+
+ ```ts
+ import { createRouter } from '@backstage/plugin-azure-devops-backend';
+ import { Router } from 'express';
+ import type { PluginEnvironment } from '../types';
+
+ export default function createPlugin({
+ logger,
+ config,
+ }: PluginEnvironment): Promise {
+ return createRouter({ logger, config });
+ }
+ ```
+
+3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`:
+
+ ```ts
+ import azureDevOps from './plugins/azuredevops';
+ // ...
+ async function main() {
+ // ...
+ const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops'));
+ apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv));
+ ```
+
+4. Now run `yarn start-backend` from the repo root
+5. Finally open `http://localhost:7000/api/azure-devops/health` in a browser and it should return `{"status":"ok"}`
+
+### Frontend
+
+To get the frontend working you'll need to do the following two steps:
+
+1. First we need to add the @backstage/plugin-azure-devops package to your frontend app:
+
+ ```bash
+ # From your Backstage root directory
+ cd packages/app
+ yarn add @backstage/plugin-azure-devops
+ ```
+
+2. Second we need to add the `EntityAzurePipelinesContent` extension to the entity page in your app:
+
+ ```tsx
+ // In packages/app/src/components/catalog/EntityPage.tsx
+ import {
+ EntityAzurePipelinesContent,
+ isAzureDevOpsAvailable,
+ } from '@backstage/plugin-azure-devops';
+
+ // For example in the CI/CD section
+ const cicdContent = (
+
+ // ...
+
+ // Set defaultLimit to the max number of builds you would like to be able to see
+ // the default if not set is 10
+
+
+ // ...
+
+ ```
+
+### Entity Annotation
+
+You need to add the following annotation to any entities you want to be able to use the Azure Devops plugin with:
+
+```yaml
+dev.azure.com/project-repo: /
+```
+
+Let's break this down a little: `` will be the name of your Team Project and `` will be the name of your repository which needs to be part of the Team Project you entered for ``.
+
+Here's what that will look like in action:
+
+```yaml
+# Example catalog-info.yaml entity definition file
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ # ...
+ annotations:
+ dev.azure.com/project-repo: my-project/my-repo
+spec:
+ type: service
+ # ...
+```
+
+## Features
+
+- Lists the top _n_ builds for a given repository where _n_ is the value configured for `top`
+
+## Limitations
+
+- Currently multiple organizations is not supported
diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md
new file mode 100644
index 0000000000..fbe28c179c
--- /dev/null
+++ b/plugins/azure-devops/api-report.md
@@ -0,0 +1,37 @@
+## API Report File for "@backstage/plugin-azure-devops"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+///
+
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+import { RouteRef } from '@backstage/core-plugin-api';
+
+// Warning: (ae-missing-release-tag) "azureDevOpsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const azureDevOpsPlugin: BackstagePlugin<
+ {
+ entityContent: RouteRef;
+ },
+ {}
+>;
+
+// Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const EntityAzurePipelinesContent: ({
+ defaultLimit,
+}: {
+ defaultLimit?: number | undefined;
+}) => JSX.Element;
+
+// Warning: (ae-missing-release-tag) "isAzureDevOpsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const isAzureDevOpsAvailable: (entity: Entity) => boolean;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/azure-devops/dev/index.tsx b/plugins/azure-devops/dev/index.tsx
new file mode 100644
index 0000000000..8d1c3ef6ea
--- /dev/null
+++ b/plugins/azure-devops/dev/index.tsx
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createDevApp } from '@backstage/dev-utils';
+import { azureDevOpsPlugin } from '../src/plugin';
+
+createDevApp().registerPlugin(azureDevOpsPlugin).render();
diff --git a/plugins/azure-devops/docs/azure-devops-builds.png b/plugins/azure-devops/docs/azure-devops-builds.png
new file mode 100644
index 0000000000..4788a185ed
Binary files /dev/null and b/plugins/azure-devops/docs/azure-devops-builds.png differ
diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json
new file mode 100644
index 0000000000..3aa545cc27
--- /dev/null
+++ b/plugins/azure-devops/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@backstage/plugin-azure-devops",
+ "version": "0.1.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": false,
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/azure-devops"
+ },
+ "scripts": {
+ "build": "backstage-cli plugin:build",
+ "start": "backstage-cli plugin:serve",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "diff": "backstage-cli plugin:diff",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/catalog-model": "^0.9.3",
+ "@backstage/core-components": "^0.6.1",
+ "@backstage/core-plugin-api": "^0.1.10",
+ "@backstage/errors": "^0.1.2",
+ "@backstage/plugin-catalog-react": "^0.5.1",
+ "@backstage/theme": "^0.2.10",
+ "@material-ui/core": "^4.12.2",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "azure-devops-node-api": "^11.0.1",
+ "luxon": "^2.0.2",
+ "react": "^16.13.1",
+ "react-dom": "^16.13.1",
+ "react-router": "6.0.0-beta.0",
+ "react-use": "^17.2.4"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.7.15",
+ "@backstage/core-app-api": "^0.1.16",
+ "@backstage/dev-utils": "^0.2.11",
+ "@backstage/test-utils": "^0.1.18",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^11.2.5",
+ "@testing-library/user-event": "^13.1.8",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^14.14.32",
+ "cross-fetch": "^3.0.6",
+ "msw": "^0.29.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts
new file mode 100644
index 0000000000..f0bad53b43
--- /dev/null
+++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { RepoBuild, RepoBuildOptions } from './types';
+import { createApiRef } from '@backstage/core-plugin-api';
+
+export const azureDevOpsApiRef = createApiRef({
+ id: 'plugin.azure-devops.service',
+ description:
+ 'Used by the Azure DevOps plugin to make requests to accompanying backend',
+});
+
+export interface AzureDevOpsApi {
+ getRepoBuilds(
+ projectName: string,
+ repoName: string,
+ options?: RepoBuildOptions,
+ ): Promise<{ items: RepoBuild[] }>;
+}
diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts
new file mode 100644
index 0000000000..e03428e511
--- /dev/null
+++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AzureDevOpsApi } from './AzureDevOpsApi';
+import { RepoBuild, RepoBuildOptions } from './types';
+import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
+import { ResponseError } from '@backstage/errors';
+
+export class AzureDevOpsClient implements AzureDevOpsApi {
+ private readonly discoveryApi: DiscoveryApi;
+ private readonly identityApi: IdentityApi;
+
+ constructor(options: {
+ discoveryApi: DiscoveryApi;
+ identityApi: IdentityApi;
+ }) {
+ this.discoveryApi = options.discoveryApi;
+ this.identityApi = options.identityApi;
+ }
+
+ async getRepoBuilds(
+ projectName: string,
+ repoName: string,
+ options?: RepoBuildOptions,
+ ): Promise<{ items: RepoBuild[] }> {
+ const items = await this.get(
+ `repo-builds/${projectName}/${repoName}?top=${options?.top}`,
+ );
+ return { items };
+ }
+
+ private async get(path: string): Promise {
+ const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`;
+ const url = new URL(path, baseUrl);
+
+ const idToken = await this.identityApi.getIdToken();
+ const response = await fetch(url.toString(), {
+ headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
+ });
+
+ if (!response.ok) {
+ throw await ResponseError.fromResponse(response);
+ }
+
+ return response.json() as Promise;
+ }
+}
diff --git a/plugins/azure-devops/src/api/index.ts b/plugins/azure-devops/src/api/index.ts
new file mode 100644
index 0000000000..c69636222f
--- /dev/null
+++ b/plugins/azure-devops/src/api/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './AzureDevOpsApi';
+export * from './AzureDevOpsClient';
diff --git a/plugins/azure-devops/src/api/types.ts b/plugins/azure-devops/src/api/types.ts
new file mode 100644
index 0000000000..1fbc34231e
--- /dev/null
+++ b/plugins/azure-devops/src/api/types.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ BuildResult,
+ BuildStatus,
+} from 'azure-devops-node-api/interfaces/BuildInterfaces';
+
+export type RepoBuild = {
+ id?: number;
+ title: string;
+ link?: string;
+ status?: BuildStatus;
+ result?: BuildResult;
+ queueTime?: Date;
+ source: string;
+};
+
+export type RepoBuildOptions = {
+ top?: number;
+};
diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx
new file mode 100644
index 0000000000..bead424ffb
--- /dev/null
+++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { DateTime } from 'luxon';
+import {
+ Link,
+ Table,
+ TableColumn,
+ StatusError,
+ StatusOK,
+ StatusWarning,
+ StatusAborted,
+ StatusRunning,
+ StatusPending,
+ ResponseErrorPanel,
+} from '@backstage/core-components';
+import { Box, Typography } from '@material-ui/core';
+import { RepoBuild } from '../../api/types';
+import {
+ BuildResult,
+ BuildStatus,
+} from 'azure-devops-node-api/interfaces/BuildInterfaces';
+
+const getBuildResultComponent = (result: number | undefined) => {
+ switch (result) {
+ case BuildResult.Succeeded:
+ return (
+
+ Succeeded
+
+ );
+ case BuildResult.PartiallySucceeded:
+ return (
+
+ Partially Succeeded
+
+ );
+ case BuildResult.Failed:
+ return (
+
+ Failed
+
+ );
+ case BuildResult.Canceled:
+ return (
+
+ Canceled
+
+ );
+ case BuildResult.None:
+ default:
+ return (
+
+ Unknown
+
+ );
+ }
+};
+
+const getBuildStateComponent = (
+ status: number | undefined,
+ result: number | undefined,
+) => {
+ switch (status) {
+ case BuildStatus.InProgress:
+ return (
+
+ In Progress
+
+ );
+ case BuildStatus.Completed:
+ return getBuildResultComponent(result);
+ case BuildStatus.Cancelling:
+ return (
+
+ Cancelling
+
+ );
+ case BuildStatus.Postponed:
+ return (
+
+ Postponed
+
+ );
+ case BuildStatus.NotStarted:
+ return (
+
+ Not Started
+
+ );
+ case BuildStatus.None:
+ default:
+ return (
+
+ Unknown
+
+ );
+ }
+};
+
+const columns: TableColumn[] = [
+ {
+ title: 'ID',
+ field: 'id',
+ highlight: false,
+ width: 'auto',
+ },
+ {
+ title: 'Build',
+ field: 'title',
+ width: 'auto',
+ render: (row: Partial) => (
+ {row.title}
+ ),
+ },
+ {
+ title: 'Source',
+ field: 'source',
+ width: 'auto',
+ },
+ {
+ title: 'State',
+ width: 'auto',
+ render: (row: Partial) => (
+
+
+ {getBuildStateComponent(row.status, row.result)}
+
+
+ ),
+ },
+ {
+ title: 'Age',
+ field: 'queueTime',
+ width: 'auto',
+ render: (row: Partial) =>
+ DateTime.fromISO(
+ row.queueTime ? row.queueTime.toString() : new Date().toString(),
+ ).toRelative(),
+ },
+];
+
+type Props = {
+ items?: RepoBuild[];
+ loading: boolean;
+ error?: any;
+};
+
+export const BuildTable = ({ items, loading, error }: Props) => {
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/plugins/azure-devops/src/components/BuildTable/index.ts b/plugins/azure-devops/src/components/BuildTable/index.ts
new file mode 100644
index 0000000000..acf20eb523
--- /dev/null
+++ b/plugins/azure-devops/src/components/BuildTable/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { BuildTable } from './BuildTable';
diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx
new file mode 100644
index 0000000000..c993922105
--- /dev/null
+++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEntity } from '@backstage/plugin-catalog-react';
+import React from 'react';
+import { useRepoBuilds } from '../../hooks/useRepoBuilds';
+import { BuildTable } from '../BuildTable/BuildTable';
+
+export const EntityPageAzurePipelines = ({
+ defaultLimit,
+}: {
+ defaultLimit?: number;
+}) => {
+ const { entity } = useEntity();
+ const { items, loading, error } = useRepoBuilds(entity, defaultLimit);
+
+ return ;
+};
diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/index.ts b/plugins/azure-devops/src/components/EntityPageAzurePipelines/index.ts
new file mode 100644
index 0000000000..c907c61b2c
--- /dev/null
+++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { EntityPageAzurePipelines } from './EntityPageAzurePipelines';
diff --git a/plugins/azure-devops/src/components/Router.tsx b/plugins/azure-devops/src/components/Router.tsx
new file mode 100644
index 0000000000..3000deb5cc
--- /dev/null
+++ b/plugins/azure-devops/src/components/Router.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { Routes, Route } from 'react-router';
+import { azureDevOpsRouteRef } from '../routes';
+import { EntityPageAzurePipelines } from './EntityPageAzurePipelines';
+import { AZURE_DEVOPS_ANNOTATION } from '../constants';
+import { Entity } from '@backstage/catalog-model';
+import { useEntity } from '@backstage/plugin-catalog-react';
+import { MissingAnnotationEmptyState } from '@backstage/core-components';
+
+export const isAzureDevOpsAvailable = (entity: Entity) =>
+ Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]);
+
+export const Router = ({ defaultLimit }: { defaultLimit?: number }) => {
+ const { entity } = useEntity();
+
+ if (!isAzureDevOpsAvailable(entity)) {
+ return ;
+ }
+
+ return (
+
+ }
+ />
+
+ );
+};
diff --git a/plugins/azure-devops/src/constants.ts b/plugins/azure-devops/src/constants.ts
new file mode 100644
index 0000000000..d6e67ff7ed
--- /dev/null
+++ b/plugins/azure-devops/src/constants.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const AZURE_DEVOPS_ANNOTATION = 'dev.azure.com/project-repo';
+export const AZURE_DEVOPS_DEFAULT_TOP: number = 10;
diff --git a/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts
new file mode 100644
index 0000000000..418badc630
--- /dev/null
+++ b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+import { AZURE_DEVOPS_ANNOTATION } from '../constants';
+
+export function useProjectRepoFromEntity(entity: Entity): {
+ project: string;
+ repo: string;
+} {
+ const [project, repo] = (
+ entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION] ?? ''
+ ).split('/');
+
+ if (!project && !repo) {
+ throw new Error(
+ 'Value for annotation dev.azure.com/project-repo was not in the correct format: /',
+ );
+ }
+
+ if (!project) {
+ throw new Error(
+ 'Project Name for annotation dev.azure.com/project-repo was not found; expected format is: /',
+ );
+ }
+
+ if (!repo) {
+ throw new Error(
+ 'Repo Name for annotation dev.azure.com/project-repo was not found; expected format is: /',
+ );
+ }
+
+ return { project, repo };
+}
diff --git a/plugins/azure-devops/src/hooks/useRepoBuilds.ts b/plugins/azure-devops/src/hooks/useRepoBuilds.ts
new file mode 100644
index 0000000000..14a8a79428
--- /dev/null
+++ b/plugins/azure-devops/src/hooks/useRepoBuilds.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useAsync } from 'react-use';
+import { Entity } from '@backstage/catalog-model';
+import { useApi } from '@backstage/core-plugin-api';
+import { azureDevOpsApiRef } from '../api';
+import { RepoBuild, RepoBuildOptions } from '../api/types';
+import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
+import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants';
+
+export function useRepoBuilds(
+ entity: Entity,
+ defaultLimit?: number,
+): {
+ items: RepoBuild[] | undefined;
+ loading: boolean;
+ error: any;
+} {
+ const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP;
+ const options: RepoBuildOptions = {
+ top: top,
+ };
+ const api = useApi(azureDevOpsApiRef);
+ const { project, repo } = useProjectRepoFromEntity(entity);
+ const { value, loading, error } = useAsync(() => {
+ return api.getRepoBuilds(project, repo, options);
+ }, [api, project, repo, entity]);
+
+ return {
+ items: value?.items,
+ loading,
+ error,
+ };
+}
diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts
new file mode 100644
index 0000000000..9a90d6ed6a
--- /dev/null
+++ b/plugins/azure-devops/src/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { azureDevOpsPlugin, EntityAzurePipelinesContent } from './plugin';
+export { isAzureDevOpsAvailable } from './components/Router';
diff --git a/plugins/azure-devops/src/plugin.test.ts b/plugins/azure-devops/src/plugin.test.ts
new file mode 100644
index 0000000000..f23639798f
--- /dev/null
+++ b/plugins/azure-devops/src/plugin.test.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { azureDevOpsPlugin } from './plugin';
+
+describe('azure-devops', () => {
+ it('should export plugin', () => {
+ expect(azureDevOpsPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts
new file mode 100644
index 0000000000..1cf6e680d0
--- /dev/null
+++ b/plugins/azure-devops/src/plugin.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { azureDevOpsApiRef } from './api/AzureDevOpsApi';
+import { AzureDevOpsClient } from './api/AzureDevOpsClient';
+import {
+ createApiFactory,
+ createPlugin,
+ createRoutableExtension,
+ discoveryApiRef,
+ identityApiRef,
+} from '@backstage/core-plugin-api';
+import { azureDevOpsRouteRef } from './routes';
+
+export const azureDevOpsPlugin = createPlugin({
+ id: 'azureDevOps',
+ apis: [
+ createApiFactory({
+ api: azureDevOpsApiRef,
+ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
+ factory: ({ discoveryApi, identityApi }) =>
+ new AzureDevOpsClient({ discoveryApi, identityApi }),
+ }),
+ ],
+ routes: {
+ entityContent: azureDevOpsRouteRef,
+ },
+});
+
+export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide(
+ createRoutableExtension({
+ name: 'EntityAzurePipelinesContent',
+ component: () => import('./components/Router').then(m => m.Router),
+ mountPoint: azureDevOpsRouteRef,
+ }),
+);
diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts
new file mode 100644
index 0000000000..81f4b48fda
--- /dev/null
+++ b/plugins/azure-devops/src/routes.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { createRouteRef } from '@backstage/core-plugin-api';
+
+export const azureDevOpsRouteRef = createRouteRef({
+ title: 'azure-devops',
+});
diff --git a/plugins/azure-devops/src/setupTests.ts b/plugins/azure-devops/src/setupTests.ts
new file mode 100644
index 0000000000..fc6dbd98f8
--- /dev/null
+++ b/plugins/azure-devops/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@testing-library/jest-dom';
+import 'cross-fetch/polyfill';