Merge pull request #7753 from awanlin/topic/azure-devops-frontend-refactoring

Azure DevOps Frontend Refactoring
This commit is contained in:
Ben Lambert
2021-10-25 15:00:32 +02:00
committed by GitHub
7 changed files with 302 additions and 134 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-azure-devops': patch
---
Azure DevOps frontend refactoring items from issue #7641
- Remove backend setup documentation and linked to the Azure DevOps backend plugin for these instructions
- Improved documentation to be easier to expand with new features in the future
- Removed Router based on feedback from maintainers
- Added tests for `getBuildResultComponent` and `getBuildStateComponent` from the BuildTable
+32 -84
View File
@@ -2,74 +2,50 @@
Website: [https://dev.azure.com/](https://dev.azure.com/)
![Azure DevOps Builds Example](./docs/azure-devops-builds.png)
## Features
### Azure Pipelines
Lists the top _n_ builds for a given repository where _n_ is a configurable value
![Azure Pipelines Builds Example](./docs/azure-devops-builds.png)
## Setup
The following sections will help you get the Azure DevOps plugin setup and running
### Configuration
### Azure DevOps Backend
The Azure DevOps plugin requires the following YAML to be added to your app-config.yaml:
You need to setup the [Azure DevOps backend plugin](https://github.com/backstage/backstage/tree/master/plugins/azure-devops-backend) before you move forward with any of these steps if you haven't already
### Entity Annotation
To be able to use the Azure DevOps plugin you need to add the following annotation to any entities you want to use it with:
```yaml
azureDevOps:
host: dev.azure.com
token: ${AZURE_TOKEN}
organization: my-company
dev.azure.com/project-repo: <project-name>/<repo-name>
```
Configuration Details:
Let's break this down a little: `<project-name>` will be the name of your Team Project and `<repo-name>` will be the name of your repository which needs to be part of the Team Project you entered for `<project-name>`.
- `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
Here's what that will look like in action:
### Backend
```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
# ...
```
Here's how to get the backend up and running:
### Azure Pipelines Component
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<Router> {
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:
To get the Azure Pipelines component 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:
@@ -101,35 +77,7 @@ To get the frontend working you'll need to do the following two steps:
</EntitySwitch>
```
### 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: <project-name>/<repo-name>
```
Let's break this down a little: `<project-name>` will be the name of your Team Project and `<repo-name>` will be the name of your repository which needs to be part of the Team Project you entered for `<project-name>`.
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
- Currently multiple organizations are not supported
- Mixing Azure DevOps Services (cloud) and Azure DevOps Server (on-premise) is not supported
@@ -0,0 +1,241 @@
/*
* 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';
import { getBuildResultComponent, getBuildStateComponent } from './BuildTable';
import { renderInTestApp } from '@backstage/test-utils';
describe('getBuildResultComponent', () => {
describe('getBuildResultComponent with Succeeded result', () => {
it('should return Status ok Succeeded', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildResultComponent(BuildResult.Succeeded),
);
expect(getByLabelText('Status ok')).toBeInTheDocument();
expect(getByText('Succeeded')).toBeInTheDocument();
});
});
describe('getBuildResultComponent with Partially Succeeded result', () => {
it('should return Status warning Partially Succeeded', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildResultComponent(BuildResult.PartiallySucceeded),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Partially Succeeded')).toBeInTheDocument();
});
});
describe('getBuildResultComponent with Failed result', () => {
it('should return Status error Failed', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildResultComponent(BuildResult.Failed),
);
expect(getByLabelText('Status error')).toBeInTheDocument();
expect(getByText('Failed')).toBeInTheDocument();
});
});
describe('getBuildResultComponent with Canceled result', () => {
it('should return Status aborted Canceled', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildResultComponent(BuildResult.Canceled),
);
expect(getByLabelText('Status aborted')).toBeInTheDocument();
expect(getByText('Canceled')).toBeInTheDocument();
});
});
describe('getBuildResultComponent with None result', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildResultComponent(BuildResult.None),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
describe('getBuildResultComponent with undefined result', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildResultComponent(undefined),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
});
describe('getBuildStateComponent', () => {
describe('getBuildStateComponent with In Progress status and undefined result', () => {
it('should return Status running In Progress', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.InProgress, undefined),
);
expect(getByLabelText('Status running')).toBeInTheDocument();
expect(getByText('In Progress')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Completed status and Succeeded result', () => {
it('should return Status ok Succeeded', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Completed, BuildResult.Succeeded),
);
expect(getByLabelText('Status ok')).toBeInTheDocument();
expect(getByText('Succeeded')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Completed status and Partially Succeeded result', () => {
it('should return Status warning Partially Succeeded', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(
BuildStatus.Completed,
BuildResult.PartiallySucceeded,
),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Partially Succeeded')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Completed status and Failed result', () => {
it('should return Status error Failed', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Completed, BuildResult.Failed),
);
expect(getByLabelText('Status error')).toBeInTheDocument();
expect(getByText('Failed')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Completed status and Canceled result', () => {
it('should return Status aborted Canceled', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Completed, BuildResult.Canceled),
);
expect(getByLabelText('Status aborted')).toBeInTheDocument();
expect(getByText('Canceled')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Completed status and None result', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Completed, BuildResult.None),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Completed status and undefined result', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Completed, undefined),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
// TODO: Add remaining Completed iterations
describe('getBuildStateComponent with Cancelling status and undefined result', () => {
it('should return Status aborted Cancelling', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Cancelling, undefined),
);
expect(getByLabelText('Status aborted')).toBeInTheDocument();
expect(getByText('Cancelling')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Postponed status and undefined result', () => {
it('should return Status pending Postponed', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.Postponed, undefined),
);
expect(getByLabelText('Status pending')).toBeInTheDocument();
expect(getByText('Postponed')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with Not Started status and undefined result', () => {
it('should return Status aborted Not Started', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.NotStarted, undefined),
);
expect(getByLabelText('Status aborted')).toBeInTheDocument();
expect(getByText('Not Started')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with None status and undefined result', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(BuildStatus.None, undefined),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with undefined and undefined result', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(undefined, undefined),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
describe('getBuildStateComponent with undefined and any valid BuildResult', () => {
it('should return Status warning Unknown', async () => {
const { getByLabelText, getByText } = await renderInTestApp(
getBuildStateComponent(undefined, BuildResult.Succeeded),
);
expect(getByLabelText('Status warning')).toBeInTheDocument();
expect(getByText('Unknown')).toBeInTheDocument();
});
});
});
@@ -36,7 +36,7 @@ import { DateTime } from 'luxon';
import React from 'react';
import { RepoBuild } from '../../api/types';
const getBuildResultComponent = (result: number | undefined) => {
export const getBuildResultComponent = (result: number | undefined) => {
switch (result) {
case BuildResult.Succeeded:
return (
@@ -72,7 +72,7 @@ const getBuildResultComponent = (result: number | undefined) => {
}
};
const getBuildStateComponent = (
export const getBuildStateComponent = (
status: number | undefined,
result: number | undefined,
) => {
@@ -1,43 +0,0 @@
/*
* 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 <MissingAnnotationEmptyState annotation={AZURE_DEVOPS_ANNOTATION} />;
}
return (
<Routes>
<Route
path={`/${azureDevOpsRouteRef.path}`}
element={<EntityPageAzurePipelines defaultLimit={defaultLimit} />}
/>
</Routes>
);
};
+5 -2
View File
@@ -13,5 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { azureDevOpsPlugin, EntityAzurePipelinesContent } from './plugin';
export { isAzureDevOpsAvailable } from './components/Router';
export {
azureDevOpsPlugin,
EntityAzurePipelinesContent,
isAzureDevOpsAvailable,
} from './plugin';
+12 -3
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { azureDevOpsApiRef } from './api/AzureDevOpsApi';
import { AzureDevOpsClient } from './api/AzureDevOpsClient';
import {
createApiFactory,
createPlugin,
@@ -23,8 +21,16 @@ import {
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { AZURE_DEVOPS_ANNOTATION } from './constants';
import { AzureDevOpsClient } from './api/AzureDevOpsClient';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from './api/AzureDevOpsApi';
import { azureDevOpsRouteRef } from './routes';
export const isAzureDevOpsAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]);
export const azureDevOpsPlugin = createPlugin({
id: 'azureDevOps',
apis: [
@@ -43,7 +49,10 @@ export const azureDevOpsPlugin = createPlugin({
export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide(
createRoutableExtension({
name: 'EntityAzurePipelinesContent',
component: () => import('./components/Router').then(m => m.Router),
component: () =>
import('./components/EntityPageAzurePipelines').then(
m => m.EntityPageAzurePipelines,
),
mountPoint: azureDevOpsRouteRef,
}),
);