diff --git a/.changeset/old-months-attack.md b/.changeset/old-months-attack.md new file mode 100644 index 0000000000..c97d7d8f46 --- /dev/null +++ b/.changeset/old-months-attack.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-devops': minor +--- + +Updated to support cases where only Azure Pipelines are being used by adding an annotation that get builds by definition name diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 8af61062a3..ba0eacf97e 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -37,6 +37,7 @@ import { EntityAzurePipelinesContent, EntityAzurePullRequestsContent, isAzureDevOpsAvailable, + isAzurePipelinesAvailable, } from '@backstage/plugin-azure-devops'; import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { @@ -202,7 +203,7 @@ export const cicdContent = ( - + diff --git a/plugins/azure-devops/README.md b/plugins/azure-devops/README.md index 7b39dae443..db1af06a01 100644 --- a/plugins/azure-devops/README.md +++ b/plugins/azure-devops/README.md @@ -49,6 +49,16 @@ spec: # ... ``` +#### Azure Pipelines Only + +If you are only using Azure Pipelines along with a different SCM tool then you can use the following annotation to see Builds: + +```yaml +dev.azure.com/project-definition: / +``` + +In this case `` will be the name of your Team Project and `` will be the name of the Build Definition you would like to see Builds for. If the Build Definition name has spaces in it make sure to put quotes around it + ### Azure Pipelines Component To get the Azure Pipelines component working you'll need to do the following two steps: diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index 11e238e6f5..eaba90664b 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -199,6 +199,11 @@ export enum FilterType { // @public (undocumented) export const isAzureDevOpsAvailable: (entity: Entity) => boolean; +// Warning: (ae-missing-release-tag) "isAzurePipelinesAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const isAzurePipelinesAvailable: (entity: Entity) => boolean; + // Warning: (ae-missing-release-tag) "PullRequestColumnConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index 89eec1bbbc..b727ce7dc9 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -15,6 +15,8 @@ */ import { + BuildRun, + BuildRunOptions, DashboardPullRequest, PullRequest, PullRequestOptions, @@ -49,4 +51,11 @@ export interface AzureDevOpsApi { getAllTeams(): Promise; getUserTeamIds(userId: string): Promise; + + getBuildRuns( + projectName: string, + repoName?: string, + definitionName?: string, + options?: BuildRunOptions, + ): Promise<{ items: BuildRun[] }>; } diff --git a/plugins/azure-devops/src/api/AzureDevOpsClient.ts b/plugins/azure-devops/src/api/AzureDevOpsClient.ts index 84ade8800d..688e5aa426 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsClient.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsClient.ts @@ -15,6 +15,8 @@ */ import { + BuildRun, + BuildRunOptions, DashboardPullRequest, PullRequest, PullRequestOptions, @@ -91,6 +93,29 @@ export class AzureDevOpsClient implements AzureDevOpsApi { public getUserTeamIds(userId: string): Promise { return this.get(`users/${userId}/team-ids`); } + + public async getBuildRuns( + projectName: string, + repoName?: string, + definitionName?: string, + options?: BuildRunOptions, + ): Promise<{ items: BuildRun[] }> { + const queryString = new URLSearchParams(); + if (repoName) { + queryString.append('repoName', repoName); + } + if (definitionName) { + queryString.append('definitionName', definitionName); + } + if (options?.top) { + queryString.append('top', options.top.toString()); + } + const urlSegment = `builds/${encodeURIComponent( + projectName, + )}?${queryString}`; + const items = await this.get(urlSegment); + return { items }; + } private async get(path: string): Promise { const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`; diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx index 84980b0b51..63d646b06a 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.stories.tsx @@ -16,8 +16,8 @@ import { BuildResult, + BuildRun, BuildStatus, - RepoBuild, } from '@backstage/plugin-azure-devops-common'; import { BuildTable } from './BuildTable'; @@ -42,8 +42,8 @@ const buildStatuses: Array<[BuildStatus, BuildResult]> = [ [BuildStatus.None, BuildResult.None], // Unknown ]; -const generateTestData = (rows = 10): RepoBuild[] => { - const repoBuilds: RepoBuild[] = []; +const generateTestData = (rows = 10): BuildRun[] => { + const buildRuns: BuildRun[] = []; for (let i = 0; i < rows; i++) { const [status, result] = buildStatuses[i] ?? [ @@ -51,7 +51,7 @@ const generateTestData = (rows = 10): RepoBuild[] => { BuildResult.Succeeded, ]; - repoBuilds.push({ + buildRuns.push({ id: rows - i + 12534, title: `backstage ci - 1.0.0-preview-${rows - i}`, status, @@ -62,7 +62,7 @@ const generateTestData = (rows = 10): RepoBuild[] => { }); } - return repoBuilds; + return buildRuns; }; export const Default = () => ( diff --git a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx index 463bf15d78..68bf4102fc 100644 --- a/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx +++ b/plugins/azure-devops/src/components/BuildTable/BuildTable.tsx @@ -17,8 +17,8 @@ import { Box, Typography } from '@material-ui/core'; import { BuildResult, + BuildRun, BuildStatus, - RepoBuild, } from '@backstage/plugin-azure-devops-common'; import { Link, @@ -126,7 +126,7 @@ const columns: TableColumn[] = [ title: 'Build', field: 'title', width: 'auto', - render: (row: Partial) => ( + render: (row: Partial) => ( {row.title} ), }, @@ -138,7 +138,7 @@ const columns: TableColumn[] = [ { title: 'State', width: 'auto', - render: (row: Partial) => ( + render: (row: Partial) => ( {getBuildStateComponent(row.status, row.result)} @@ -150,7 +150,7 @@ const columns: TableColumn[] = [ title: 'Duration', field: 'queueTime', width: 'auto', - render: (row: Partial) => ( + render: (row: Partial) => ( {getDurationFromDates(row.startTime, row.finishTime)} @@ -162,7 +162,7 @@ const columns: TableColumn[] = [ title: 'Age', field: 'queueTime', width: 'auto', - render: (row: Partial) => + render: (row: Partial) => (row.queueTime ? DateTime.fromISO(row.queueTime) : DateTime.now() @@ -171,7 +171,7 @@ const columns: TableColumn[] = [ ]; type BuildTableProps = { - items?: RepoBuild[]; + items?: BuildRun[]; loading: boolean; error?: Error; }; diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx index c993922105..5957e82eef 100644 --- a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx +++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx @@ -14,10 +14,11 @@ * 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'; +import React from 'react'; +import { useAnnotationFromEntity } from '../../hooks/useAnnotationFromEntity'; +import { useBuildRuns } from '../../hooks/useBuildRuns'; +import { useEntity } from '@backstage/plugin-catalog-react'; export const EntityPageAzurePipelines = ({ defaultLimit, @@ -25,7 +26,15 @@ export const EntityPageAzurePipelines = ({ defaultLimit?: number; }) => { const { entity } = useEntity(); - const { items, loading, error } = useRepoBuilds(entity, defaultLimit); + + const { project, repo, definition } = useAnnotationFromEntity(entity); + + const { items, loading, error } = useBuildRuns( + project, + defaultLimit, + repo, + definition, + ); return ; }; diff --git a/plugins/azure-devops/src/constants.ts b/plugins/azure-devops/src/constants.ts index d6e67ff7ed..8a920b59e2 100644 --- a/plugins/azure-devops/src/constants.ts +++ b/plugins/azure-devops/src/constants.ts @@ -14,5 +14,7 @@ * limitations under the License. */ -export const AZURE_DEVOPS_ANNOTATION = 'dev.azure.com/project-repo'; +export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo'; +export const AZURE_DEVOPS_DEFINITION_ANNOTATION = + 'dev.azure.com/project-definition'; export const AZURE_DEVOPS_DEFAULT_TOP: number = 10; diff --git a/plugins/azure-devops/src/hooks/useAnnotationFromEntity.ts b/plugins/azure-devops/src/hooks/useAnnotationFromEntity.ts new file mode 100644 index 0000000000..06e3f58549 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useAnnotationFromEntity.ts @@ -0,0 +1,95 @@ +/* + * 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_DEFINITION_ANNOTATION, + AZURE_DEVOPS_REPO_ANNOTATION, +} from '../constants'; + +export function useAnnotationFromEntity(entity: Entity): { + project: string; + repo?: string; + definition?: string; +} { + if (entity.metadata.annotations?.[AZURE_DEVOPS_DEFINITION_ANNOTATION]) { + const { project, definition } = getProjectDefinition( + entity.metadata.annotations?.[AZURE_DEVOPS_DEFINITION_ANNOTATION], + ); + const repo = undefined; + return { project, repo, definition }; + } + + const { project, repo } = getProjectRepo( + entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION] ?? '', + ); + const definition = undefined; + return { project, repo, definition }; +} + +function getProjectDefinition(annotation: string): { + project: string; + definition: string; +} { + const [project, definition] = annotation.split('/'); + + if (!project && !definition) { + throw new Error( + 'Value for annotation dev.azure.com/project-definition was not in the correct format: /', + ); + } + + if (!project) { + throw new Error( + 'Project Name for annotation dev.azure.com/project-definition was not found; expected format is: /', + ); + } + + if (!definition) { + throw new Error( + 'Definition Name for annotation dev.azure.com/project-definition was not found; expected format is: /', + ); + } + + return { project, definition }; +} + +function getProjectRepo(annotation: string): { + project: string; + repo: string; +} { + const [project, repo] = 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/useBuildRuns.ts b/plugins/azure-devops/src/hooks/useBuildRuns.ts new file mode 100644 index 0000000000..f19ebaf23f --- /dev/null +++ b/plugins/azure-devops/src/hooks/useBuildRuns.ts @@ -0,0 +1,53 @@ +/* + * 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 { + BuildRun, + BuildRunOptions, +} from '@backstage/plugin-azure-devops-common'; + +import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; + +export function useBuildRuns( + projectName: string, + defaultLimit?: number, + repoName?: string, + definitionName?: string, +): { + items?: BuildRun[]; + loading: boolean; + error?: Error; +} { + const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP; + const options: BuildRunOptions = { + top: top, + }; + + const api = useApi(azureDevOpsApiRef); + + const { value, loading, error } = useAsync(() => { + return api.getBuildRuns(projectName, repoName, definitionName, options); + }, [api, projectName, repoName, definitionName]); + + return { + items: value?.items, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts index 418badc630..b484bca009 100644 --- a/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts +++ b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts @@ -15,14 +15,14 @@ */ import { Entity } from '@backstage/catalog-model'; -import { AZURE_DEVOPS_ANNOTATION } from '../constants'; +import { AZURE_DEVOPS_REPO_ANNOTATION } from '../constants'; export function useProjectRepoFromEntity(entity: Entity): { project: string; repo: string; } { const [project, repo] = ( - entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION] ?? '' + entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION] ?? '' ).split('/'); if (!project && !repo) { diff --git a/plugins/azure-devops/src/index.ts b/plugins/azure-devops/src/index.ts index 8289a93a5c..aa052975de 100644 --- a/plugins/azure-devops/src/index.ts +++ b/plugins/azure-devops/src/index.ts @@ -19,6 +19,7 @@ export { EntityAzurePipelinesContent, EntityAzurePullRequestsContent, isAzureDevOpsAvailable, + isAzurePipelinesAvailable, AzurePullRequestsPage, } from './plugin'; diff --git a/plugins/azure-devops/src/plugin.ts b/plugins/azure-devops/src/plugin.ts index 003c7b7eee..d22dff2863 100644 --- a/plugins/azure-devops/src/plugin.ts +++ b/plugins/azure-devops/src/plugin.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { + AZURE_DEVOPS_DEFINITION_ANNOTATION, + AZURE_DEVOPS_REPO_ANNOTATION, +} from './constants'; import { azurePipelinesEntityContentRouteRef, azurePullRequestDashboardRouteRef, @@ -27,13 +31,16 @@ import { 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'; export const isAzureDevOpsAvailable = (entity: Entity) => - Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]); + Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION]); + +export const isAzurePipelinesAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION]) || + Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_DEFINITION_ANNOTATION]); export const azureDevOpsPlugin = createPlugin({ id: 'azureDevOps',