Merge pull request #7753 from awanlin/topic/azure-devops-frontend-refactoring
Azure DevOps Frontend Refactoring
This commit is contained in:
@@ -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
|
||||
@@ -2,74 +2,50 @@
|
||||
|
||||
Website: [https://dev.azure.com/](https://dev.azure.com/)
|
||||
|
||||

|
||||
## Features
|
||||
|
||||
### Azure Pipelines
|
||||
|
||||
Lists the top _n_ builds for a given repository where _n_ is a configurable value
|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user