initial changes

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
Andre Wanlin
2023-11-23 18:10:40 -06:00
parent 0c930f8df1
commit 043b724c56
18 changed files with 576 additions and 14 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-azure-devops-backend': patch
'@backstage/plugin-azure-devops-common': patch
'@backstage/plugin-azure-devops': patch
---
Introduced new `AzureDevOpsAnnotatorProcessor` that adds the needed annotations automatically. Also, moved constants to common package so they can be shared more easily
+23
View File
@@ -89,6 +89,29 @@ In your `packages/backend/src/index.ts` make the following changes:
backend.start();
```
## Processor
The Azure DevOps backend plugin includes the `AzureDevOpsAnnotatorProcessor` which will automatically add the needed annotations for you. Here's how to install it:
```diff
import { CatalogBuilder } from '@backstage/plugin-catalog-backend';
import { ScaffolderEntitiesProcessor } from '@backstage/plugin-catalog-backend-module-scaffolder-entity-model';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
+ import { AzureDevOpsAnnotatorProcessor } from '@backstage/plugin-azure-devops-backend';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const builder = await CatalogBuilder.create(env);
builder.addProcessor(new ScaffolderEntitiesProcessor());
+ builder.addProcessor(AzureDevOpsAnnotatorProcessor.fromConfig(env.config));
const { processingEngine, router } = await builder.build();
await processingEngine.start();
return router;
}
```
## Links
- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/azure-devops)
@@ -7,21 +7,44 @@ import { BackendFeature } from '@backstage/backend-plugin-api';
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { BuildDefinitionReference } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { BuildRun } from '@backstage/plugin-azure-devops-common';
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
import { Config } from '@backstage/config';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { GitTag } from '@backstage/plugin-azure-devops-common';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { Logger } from 'winston';
import { Project } from '@backstage/plugin-azure-devops-common';
import { PullRequest } from '@backstage/plugin-azure-devops-common';
import { PullRequestOptions } from '@backstage/plugin-azure-devops-common';
import { RepoBuild } from '@backstage/plugin-azure-devops-common';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Team } from '@backstage/plugin-azure-devops-common';
import { TeamMember } from '@backstage/plugin-azure-devops-common';
import { UrlReader } from '@backstage/backend-common';
import { WebApi } from 'azure-devops-node-api';
// @public (undocumented)
export class AzureDevOpsAnnotatorProcessor implements CatalogProcessor {
constructor(opts: {
scmIntegrationRegistry: ScmIntegrationRegistry;
kinds?: string[];
});
// (undocumented)
static fromConfig(
config: Config,
options?: {
kinds?: string[];
},
): AzureDevOpsAnnotatorProcessor;
// (undocumented)
getProcessorName(): string;
// (undocumented)
preProcessEntity(entity: Entity, location: LocationSpec): Promise<Entity>;
}
// @public (undocumented)
export class AzureDevOpsApi {
constructor(logger: Logger, webApi: WebApi, urlReader: UrlReader);
@@ -30,12 +30,17 @@
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-azure-devops-common": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@types/express": "^4.17.6",
"azure-devops-node-api": "^11.0.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.27",
"p-limit": "^3.1.0",
"winston": "^3.2.1",
@@ -23,3 +23,4 @@
export { AzureDevOpsApi } from './api';
export * from './service/router';
export { azureDevOpsPlugin as default } from './plugin';
export { AzureDevOpsAnnotatorProcessor } from './processor';
@@ -0,0 +1,271 @@
/*
* Copyright 2023 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 { ConfigReader } from '@backstage/config';
import { AzureDevOpsAnnotatorProcessor } from './AzureDevOpsAnnotatorProcessor';
import { LocationSpec } from '@backstage/plugin-catalog-common';
describe('AzureDevOpsAnnotatorProcessor', () => {
it('adds annotation', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
};
const processor = AzureDevOpsAnnotatorProcessor.fromConfig(
new ConfigReader({}),
);
expect(await processor.preProcessEntity(entity, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'dev.azure.com/organization',
'dev.azure.com/project-repo': 'project/repository',
},
},
});
});
it('adds annotation for Azure DevOps Server url', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://example.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
};
const processor = AzureDevOpsAnnotatorProcessor.fromConfig(
new ConfigReader({
integrations: {
azure: [
{
host: 'example.com',
credentials: [
{
personalAccessToken: 'pat',
},
],
},
],
},
}),
);
expect(await processor.preProcessEntity(entity, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'example.com/organization',
'dev.azure.com/project-repo': 'project/repository',
},
},
});
});
it('adds annotation for TFS subpath url', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://example.com/tfs/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
};
const processor = AzureDevOpsAnnotatorProcessor.fromConfig(
new ConfigReader({
integrations: {
azure: [
{
host: 'example.com',
credentials: [
{
personalAccessToken: 'pat',
},
],
},
],
},
}),
);
expect(await processor.preProcessEntity(entity, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'example.com/tfs/organization',
'dev.azure.com/project-repo': 'project/repository',
},
},
});
});
it('does not override existing annotation', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'myhost/myorg',
'dev.azure.com/project-repo': 'myproj/myrepo',
},
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
};
const processor = AzureDevOpsAnnotatorProcessor.fromConfig(
new ConfigReader({}),
);
expect(await processor.preProcessEntity(entity, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'myhost/myorg',
'dev.azure.com/project-repo': 'myproj/myrepo',
},
},
});
});
it('should not add annotation for other providers', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://not-in-mock-config.example.com/backstage/backstage/-/blob/master/catalog-info.yaml',
};
const processor = AzureDevOpsAnnotatorProcessor.fromConfig(
new ConfigReader({}),
);
expect(await processor.preProcessEntity(entity, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
});
});
it('should only process applicable kinds', async () => {
const component: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const api: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-component',
},
};
const system: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'System',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
};
const processor = AzureDevOpsAnnotatorProcessor.fromConfig(
new ConfigReader({}),
{ kinds: ['API', 'Component'] },
);
expect(await processor.preProcessEntity(component, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'dev.azure.com/organization',
'dev.azure.com/project-repo': 'project/repository',
},
},
});
expect(await processor.preProcessEntity(api, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-component',
annotations: {
'dev.azure.com/host-org': 'dev.azure.com/organization',
'dev.azure.com/project-repo': 'project/repository',
},
},
});
expect(await processor.preProcessEntity(system, location)).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'System',
metadata: {
name: 'my-component',
},
});
});
});
@@ -0,0 +1,127 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import {
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import { identity, merge, pickBy } from 'lodash';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
import {
AZURE_DEVOPS_HOST_ORG_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
} from '@backstage/plugin-azure-devops-common';
import { parseAzureDevOpsUrl } from '../utils';
/** @public */
export class AzureDevOpsAnnotatorProcessor implements CatalogProcessor {
constructor(
private readonly opts: {
scmIntegrationRegistry: ScmIntegrationRegistry;
kinds?: string[];
},
) {}
getProcessorName(): string {
return 'AzureDevOpsAnnotatorProcessor';
}
static fromConfig(
config: Config,
options?: { kinds?: string[] },
): AzureDevOpsAnnotatorProcessor {
return new AzureDevOpsAnnotatorProcessor({
scmIntegrationRegistry: ScmIntegrations.fromConfig(config),
kinds: options?.kinds,
});
}
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
const applicableKinds = (this.opts.kinds ?? ['Component']).map(k =>
k.toLocaleLowerCase('en-US'),
);
if (
!applicableKinds.includes(entity.kind.toLocaleLowerCase('en-US')) ||
location.type !== 'url'
) {
return entity;
}
const scmIntegration = this.opts.scmIntegrationRegistry.byUrl(
location.target,
);
if (!scmIntegration) {
return entity;
}
if (scmIntegration.type !== 'azure') {
return entity;
}
const { host, org, project, repo } = parseAzureDevOpsUrl(location.target);
if (!org || !project || !repo) {
return entity;
}
const hostOrgAnnotation = AZURE_DEVOPS_HOST_ORG_ANNOTATION;
let hostOrgValue = entity.metadata.annotations?.[hostOrgAnnotation];
if (!hostOrgValue) {
hostOrgValue = `${host}/${org}`;
}
const projectRepoAnnotation = AZURE_DEVOPS_REPO_ANNOTATION;
let projectRepoValue = entity.metadata.annotations?.[projectRepoAnnotation];
if (!projectRepoValue) {
projectRepoValue = `${project}/${repo}`;
}
const result = merge(
{
metadata: {
annotations: pickBy(
{
[hostOrgAnnotation]: hostOrgValue,
},
identity,
),
},
},
entity,
);
return merge(
{
metadata: {
annotations: pickBy(
{
[projectRepoAnnotation]: projectRepoValue,
},
identity,
),
},
},
result,
);
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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 { AzureDevOpsAnnotatorProcessor } from './AzureDevOpsAnnotatorProcessor';
@@ -28,6 +28,7 @@ import {
getPullRequestLink,
replaceReadme,
buildEncodedUrl,
parseAzureDevOpsUrl,
} from './azure-devops-utils';
import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { UrlReader } from '@backstage/backend-common';
@@ -295,3 +296,38 @@ describe('buildEncodedUrl', () => {
);
});
});
describe('parseAzureDevOpsUrl', () => {
it('parses Azure DevOps Cloud url', async () => {
const result = parseAzureDevOpsUrl(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
);
expect(result.host).toEqual('dev.azure.com');
expect(result.org).toEqual('organization');
expect(result.project).toEqual('project');
expect(result.repo).toEqual('repository');
});
it('parses Azure DevOps Server url', async () => {
const result = parseAzureDevOpsUrl(
'https://server.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
);
expect(result.host).toEqual('server.com');
expect(result.org).toEqual('organization');
expect(result.project).toEqual('project');
expect(result.repo).toEqual('repository');
});
it('parses TFS subpath Url', async () => {
const result = parseAzureDevOpsUrl(
'https://server.com/tfs/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
);
expect(result.host).toEqual('server.com/tfs');
expect(result.org).toEqual('organization');
expect(result.project).toEqual('project');
expect(result.repo).toEqual('repository');
});
});
@@ -328,3 +328,29 @@ export function extractPartsFromAsset(content: string): {
path: path.startsWith('.') ? path.substring(1, path.length) : path,
};
}
export function parseAzureDevOpsUrl(sourceUrl: string) {
const url = new URL(sourceUrl);
let host = url.host;
let org;
let project;
let repo;
const parts = url.pathname.split('/').map(part => decodeURIComponent(part));
if (parts[2] === '_git') {
org = parts[1];
project = repo = parts[3];
} else if (parts[3] === '_git') {
org = parts[1];
project = parts[2];
repo = parts[4];
} else if (parts[4] === '_git') {
host = `${host}/${parts[1]}`;
org = parts[2];
project = parts[3];
repo = parts[5];
}
return { host, org, project, repo };
}
+16
View File
@@ -3,6 +3,22 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public (undocumented)
export const AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION =
'dev.azure.com/build-definition';
// @public (undocumented)
export const AZURE_DEVOPS_DEFAULT_TOP: number;
// @public (undocumented)
export const AZURE_DEVOPS_HOST_ORG_ANNOTATION = 'dev.azure.com/host-org';
// @public (undocumented)
export const AZURE_DEVOPS_PROJECT_ANNOTATION = 'dev.azure.com/project';
// @public (undocumented)
export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo';
// @public (undocumented)
export enum BuildResult {
Canceled = 32,
@@ -14,9 +14,14 @@
* limitations under the License.
*/
/** @public */
export const AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION =
'dev.azure.com/build-definition';
/** @public */
export const AZURE_DEVOPS_HOST_ORG_ANNOTATION = 'dev.azure.com/host-org';
/** @public */
export const AZURE_DEVOPS_PROJECT_ANNOTATION = 'dev.azure.com/project';
/** @public */
export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo';
/** @public */
export const AZURE_DEVOPS_DEFAULT_TOP: number = 10;
+1
View File
@@ -15,3 +15,4 @@
*/
export * from './types';
export * from './constants';
@@ -15,11 +15,11 @@
*/
import {
AZURE_DEVOPS_DEFAULT_TOP,
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/lib/useAsync';
@@ -15,12 +15,12 @@
*/
import {
AZURE_DEVOPS_DEFAULT_TOP,
PullRequest,
PullRequestOptions,
PullRequestStatus,
} from '@backstage/plugin-azure-devops-common';
import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
+5 -5
View File
@@ -14,11 +14,6 @@
* limitations under the License.
*/
import {
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
} from './constants';
import {
azurePipelinesEntityContentRouteRef,
azurePullRequestDashboardRouteRef,
@@ -37,6 +32,11 @@ import {
import { AzureDevOpsClient } from './api/AzureDevOpsClient';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from './api/AzureDevOpsApi';
import {
AZURE_DEVOPS_REPO_ANNOTATION,
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
} from '@backstage/plugin-azure-devops-common';
/** @public */
export const isAzureDevOpsAvailable = (entity: Entity) =>
@@ -14,14 +14,13 @@
* limitations under the License.
*/
import {
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
AZURE_DEVOPS_HOST_ORG_ANNOTATION,
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
} from '../constants';
import { Entity } from '@backstage/catalog-model';
import {
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
AZURE_DEVOPS_HOST_ORG_ANNOTATION,
} from '@backstage/plugin-azure-devops-common';
export function getAnnotationValuesFromEntity(entity: Entity): {
project: string;
+5
View File
@@ -5049,14 +5049,19 @@ __metadata:
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-azure-devops-common": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@types/express": ^4.17.6
"@types/supertest": ^2.0.8
azure-devops-node-api: ^11.0.1
express: ^4.17.1
express-promise-router: ^4.1.0
lodash: ^4.17.21
mime-types: ^2.1.27
msw: ^1.0.0
p-limit: ^3.1.0