diff --git a/.changeset/green-parrots-thank.md b/.changeset/green-parrots-thank.md new file mode 100644 index 0000000000..9296a5119a --- /dev/null +++ b/.changeset/green-parrots-thank.md @@ -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 diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index edf26ecfb3..47a0c4debd 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -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: / ``` -Configuration Details: +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 ``. -- `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 { - 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: ``` -### 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 +- Currently multiple organizations are not supported +- Mixing Azure DevOps Services (cloud) and Azure DevOps Server (on-premise) is not supported diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts b/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts new file mode 100644 index 0000000000..42408f37b3 --- /dev/null +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.test.ts @@ -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(); + }); + }); +}); diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx index 7516ae52a4..a308afb81b 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx @@ -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, ) => { diff --git a/plugins/azure-devops/src/components/Router.tsx b/plugins/azure-devops/src/components/Router.tsx deleted file mode 100644 index 3000deb5cc..0000000000 --- a/plugins/azure-devops/src/components/Router.tsx +++ /dev/null @@ -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 ; - } - - return ( - - } - /> - - ); -}; diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index 9a90d6ed6a..71d3b654fe 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -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'; diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index 1cf6e680d0..ab6d816460 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -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, }), );