Merge pull request #11697 from alecjacobs5401/ajacobs/pagerduty-plugin-service-id-annotation

feat(plugins/pagerduty): support pagerduty.com/service-id annotation
This commit is contained in:
Patrik Oldsberg
2022-06-20 14:09:09 +02:00
committed by GitHub
30 changed files with 1334 additions and 264 deletions
+21
View File
@@ -7,6 +7,7 @@
- The Backstage PagerDuty plugin allows PagerDuty information about a Backstage entity to be displayed within Backstage. This includes active incidents, recent change events, as well as the current on-call responders' names, email addresses, and links to their profiles in PagerDuty.
- Incidents can be manually triggered via the plugin with a user-provided description, which will in turn notify the current on-call responders.
- _Note: This feature is only available when providing the `pagerduty.com/integration-key` annotation_
- Change events will be displayed in a separate tab. If the change event payload has additional links the first link only will be rendered.
# Requirements
@@ -117,6 +118,26 @@ This will proxy the request by adding an `Authorization` header with the provide
### Optional configuration
#### Annotating with Service ID
If you want to integrate a PagerDuty service with Backstage but don't want to use an integration key, you can also [annotate](https://backstage.io/docs/features/software-catalog/descriptor-format#annotations-optional) the appropriate entity with a PagerDuty Service ID instead
```yaml
annotations:
pagerduty.com/service-id: [SERVICE_ID]
```
This service ID can be found by navigating to a Service within PagerDuty and pulling the ID value out of the URL.
1. From the **Configuration** menu within PagerDuty, select **Services**.
2. Click the **name** of the service you want to represent for your Entity.
Your browser URL should now be located at `https://pagerduty.com/service-directory/[SERVICE_ID]`.
_Note: When annotating with `pagerduty.com/service-id`, the feature to Create Incidents is not currently supported_
#### Custom Events URL
If you want to override the default URL used for events, you can add it to `app-config.yaml`:
```yaml
+156 -20
View File
@@ -10,7 +10,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { IdentityApi } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { ReactNode } from 'react';
// Warning: (ae-missing-release-tag) "EntityPagerDutyCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -31,45 +31,138 @@ export { isPluginApplicableToEntity };
// @public (undocumented)
export const pagerDutyApiRef: ApiRef<PagerDutyApi>;
// Warning: (ae-missing-release-tag) "PagerDutyAssignee" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyAssignee = {
id: string;
summary: string;
html_url: string;
};
// Warning: (ae-missing-release-tag) "PagerDutyCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const PagerDutyCard: () => JSX.Element;
// Warning: (ae-missing-release-tag) "PagerDutyChangeEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyChangeEvent = {
id: string;
integration: [
{
service: PagerDutyService;
},
];
source: string;
html_url: string;
links: [
{
href: string;
text: string;
},
];
summary: string;
timestamp: string;
};
// Warning: (ae-missing-release-tag) "PagerDutyChangeEventsResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyChangeEventsResponse = {
change_events: PagerDutyChangeEvent[];
};
// Warning: (ae-missing-release-tag) "PagerDutyClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class PagerDutyClient implements PagerDutyApi {
// Warning: (ae-forgotten-export) The symbol "ClientApiConfig" needs to be exported by the entry point index.d.ts
constructor(config: ClientApiConfig);
constructor(config: PagerDutyClientApiConfig);
// (undocumented)
static fromConfig(
configApi: ConfigApi,
discoveryApi: DiscoveryApi,
identityApi: IdentityApi,
{ discoveryApi, fetchApi }: PagerDutyClientApiDependencies,
): PagerDutyClient;
// Warning: (ae-forgotten-export) The symbol "ChangeEvent" needs to be exported by the entry point index.d.ts
//
// (undocumented)
getChangeEventsByServiceId(serviceId: string): Promise<ChangeEvent[]>;
// Warning: (ae-forgotten-export) The symbol "Incident" needs to be exported by the entry point index.d.ts
//
getChangeEventsByServiceId(
serviceId: string,
): Promise<PagerDutyChangeEventsResponse>;
// (undocumented)
getIncidentsByServiceId(serviceId: string): Promise<Incident[]>;
// Warning: (ae-forgotten-export) The symbol "OnCall" needs to be exported by the entry point index.d.ts
//
getIncidentsByServiceId(
serviceId: string,
): Promise<PagerDutyIncidentsResponse>;
// (undocumented)
getOnCallByPolicyId(policyId: string): Promise<OnCall[]>;
// Warning: (ae-forgotten-export) The symbol "Service" needs to be exported by the entry point index.d.ts
//
getOnCallByPolicyId(policyId: string): Promise<PagerDutyOnCallsResponse>;
// (undocumented)
getServiceByIntegrationKey(integrationKey: string): Promise<Service[]>;
// Warning: (ae-forgotten-export) The symbol "TriggerAlarmRequest" needs to be exported by the entry point index.d.ts
//
getServiceByEntity(entity: Entity): Promise<PagerDutyServiceResponse>;
// (undocumented)
triggerAlarm(request: TriggerAlarmRequest): Promise<Response>;
triggerAlarm(request: PagerDutyTriggerAlarmRequest): Promise<Response>;
}
// Warning: (ae-missing-release-tag) "PagerDutyClientApiConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyClientApiConfig = PagerDutyClientApiDependencies & {
eventsBaseUrl?: string;
};
// Warning: (ae-missing-release-tag) "PagerDutyClientApiDependencies" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyClientApiDependencies = {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
};
// Warning: (ae-missing-release-tag) "PagerDutyEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyEntity = {
integrationKey?: string;
serviceId?: string;
name: string;
};
// Warning: (ae-missing-release-tag) "PagerDutyIncident" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyIncident = {
id: string;
title: string;
status: string;
html_url: string;
assignments: [
{
assignee: PagerDutyAssignee;
},
];
serviceId: string;
created_at: string;
};
// Warning: (ae-missing-release-tag) "PagerDutyIncidentsResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyIncidentsResponse = {
incidents: PagerDutyIncident[];
};
// Warning: (ae-missing-release-tag) "PagerDutyOnCall" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyOnCall = {
user: PagerDutyUser;
escalation_level: number;
};
// Warning: (ae-missing-release-tag) "PagerDutyOnCallsResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyOnCallsResponse = {
oncalls: PagerDutyOnCall[];
};
// Warning: (ae-missing-release-tag) "pagerDutyPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -77,6 +170,49 @@ const pagerDutyPlugin: BackstagePlugin<{}, {}>;
export { pagerDutyPlugin };
export { pagerDutyPlugin as plugin };
// Warning: (ae-missing-release-tag) "PagerDutyService" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyService = {
id: string;
name: string;
html_url: string;
integrationKey: string;
escalation_policy: {
id: string;
user: PagerDutyUser;
html_url: string;
};
};
// Warning: (ae-missing-release-tag) "PagerDutyServiceResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyServiceResponse = {
service: PagerDutyService;
};
// Warning: (ae-missing-release-tag) "PagerDutyTriggerAlarmRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyTriggerAlarmRequest = {
integrationKey: string;
source: string;
description: string;
userName: string;
};
// Warning: (ae-missing-release-tag) "PagerDutyUser" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type PagerDutyUser = {
id: string;
summary: string;
email: string;
html_url: string;
name: string;
};
// Warning: (ae-forgotten-export) The symbol "TriggerButtonProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "TriggerButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+1
View File
@@ -37,6 +37,7 @@
"@backstage/catalog-model": "^1.0.3",
"@backstage/core-components": "^0.9.5",
"@backstage/core-plugin-api": "^1.0.3",
"@backstage/errors": "^1.0.0",
"@backstage/plugin-catalog-react": "^1.1.1",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.2",
+376
View File
@@ -0,0 +1,376 @@
/*
* Copyright 2020 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 { MockFetchApi } from '@backstage/test-utils';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { PagerDutyClient, UnauthorizedError } from './client';
import { PagerDutyService, PagerDutyUser } from '../components/types';
import { NotFoundError } from '@backstage/errors';
import { Entity } from '@backstage/catalog-model';
const mockFetch = jest.fn().mockName('fetch');
const mockDiscoveryApi: jest.Mocked<DiscoveryApi> = {
getBaseUrl: jest
.fn()
.mockName('discoveryApi')
.mockResolvedValue('http://localhost:7007/proxy'),
};
const mockFetchApi: MockFetchApi = new MockFetchApi({
baseImplementation: mockFetch,
});
let client: PagerDutyClient;
let entity: Entity;
const user: PagerDutyUser = {
name: 'person1',
id: 'p1',
summary: 'person1',
email: 'person1@example.com',
html_url: 'http://a.com/id1',
};
const service: PagerDutyService = {
id: 'def456',
name: 'pagerduty-name',
html_url: 'www.example.com',
escalation_policy: {
id: 'def',
user: user,
html_url: 'http://a.com/id1',
},
integrationKey: 'abc123',
};
const requestHeaders = {
headers: {
Accept: 'application/vnd.pagerduty+json;version=2',
'Content-Type': 'application/json',
},
method: 'GET',
};
describe('PagerDutyClient', () => {
beforeEach(() => {
mockFetch.mockReset();
client = new PagerDutyClient({
eventsBaseUrl: 'https://events.pagerduty.com/v2',
discoveryApi: mockDiscoveryApi,
fetchApi: mockFetchApi,
});
});
describe('getServiceByEntity', () => {
describe('when provided entity has an integrationKey value', () => {
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
},
},
};
});
it('queries proxy path by integration id', async () => {
mockFetch.mockResolvedValueOnce({
status: 200,
ok: true,
json: () => Promise.resolve({ services: [service] }),
});
expect(await client.getServiceByEntity(entity)).toEqual({
service,
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:7007/proxy/pagerduty/services?time_zone=UTC&include[]=integrations&include[]=escalation_policies&query=abc123',
requestHeaders,
);
});
describe('on 401 response code', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 401,
ok: false,
json: () => Promise.resolve({}),
});
});
it('throws UnauthorizedError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
UnauthorizedError,
);
});
});
describe('on 404 response code', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 404,
ok: false,
json: () => Promise.resolve({}),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
NotFoundError,
);
});
});
describe('on other non-ok response', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 500,
ok: false,
json: () =>
Promise.resolve({
errors: ['Not valid request', 'internal error occurred'],
}),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
'Request failed with 500, Not valid request internal error occurred',
);
});
});
describe('when services response is empty', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 200,
ok: true,
json: () => Promise.resolve({ services: [] }),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
NotFoundError,
);
});
});
});
describe('when provided entity has a serviceId value', () => {
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/service-id': 'def456',
},
},
};
});
it('queries proxy path by integration id', async () => {
mockFetch.mockResolvedValueOnce({
status: 200,
ok: true,
json: () => Promise.resolve({ service }),
});
expect(await client.getServiceByEntity(entity)).toEqual({
service,
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:7007/proxy/pagerduty/services/def456?time_zone=UTC&include[]=integrations&include[]=escalation_policies',
requestHeaders,
);
});
describe('on 401 response code', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 401,
ok: false,
json: () => Promise.resolve({}),
});
});
it('throws UnauthorizedError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
UnauthorizedError,
);
});
});
describe('on 404 response code', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 404,
ok: false,
json: () => Promise.resolve({}),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
NotFoundError,
);
});
});
describe('on other non-ok response', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 500,
ok: false,
json: () =>
Promise.resolve({
errors: ['Not valid request', 'internal error occurred'],
}),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
'Request failed with 500, Not valid request internal error occurred',
);
});
});
});
describe('when provided entity has both integrationKey and serviceId', () => {
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
'pagerduty.com/service-id': 'def456',
},
},
};
});
it('queries proxy path by integration id', async () => {
mockFetch.mockResolvedValueOnce({
status: 200,
ok: true,
json: () => Promise.resolve({ services: [service] }),
});
expect(await client.getServiceByEntity(entity)).toEqual({
service,
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:7007/proxy/pagerduty/services?time_zone=UTC&include[]=integrations&include[]=escalation_policies&query=abc123',
requestHeaders,
);
});
describe('on 401 response code', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 401,
ok: false,
json: () => Promise.resolve({}),
});
});
it('throws UnauthorizedError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
UnauthorizedError,
);
});
});
describe('on 404 response code', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 404,
ok: false,
json: () => Promise.resolve({}),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
NotFoundError,
);
});
});
describe('on other non-ok response', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 500,
ok: false,
json: () =>
Promise.resolve({
errors: ['Not valid request', 'internal error occurred'],
}),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
'Request failed with 500, Not valid request internal error occurred',
);
});
});
describe('when services response is empty', () => {
beforeEach(() => {
mockFetch.mockResolvedValueOnce({
status: 200,
ok: true,
json: () => Promise.resolve({ services: [] }),
});
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
NotFoundError,
);
});
});
});
describe('when provided entity has no integrationKey or serviceId values', () => {
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {},
},
};
});
it('throws NotFoundError', async () => {
await expect(client.getServiceByEntity(entity)).rejects.toThrowError(
NotFoundError,
);
expect(mockFetch).not.toHaveBeenCalled();
});
});
});
});
+64 -38
View File
@@ -14,23 +14,22 @@
* limitations under the License.
*/
import { Service, Incident, ChangeEvent, OnCall } from '../components/types';
import {
PagerDutyApi,
TriggerAlarmRequest,
ServicesResponse,
IncidentsResponse,
OnCallsResponse,
ClientApiConfig,
PagerDutyTriggerAlarmRequest,
PagerDutyServicesResponse,
PagerDutyServiceResponse,
PagerDutyIncidentsResponse,
PagerDutyOnCallsResponse,
PagerDutyClientApiDependencies,
PagerDutyClientApiConfig,
RequestOptions,
ChangeEventsResponse,
PagerDutyChangeEventsResponse,
} from './types';
import {
createApiRef,
DiscoveryApi,
ConfigApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import { createApiRef, ConfigApi } from '@backstage/core-plugin-api';
import { NotFoundError } from '@backstage/errors';
import { Entity } from '@backstage/catalog-model';
import { getPagerDutyEntity } from '../components/pagerDutyEntity';
export class UnauthorizedError extends Error {}
@@ -38,65 +37,89 @@ export const pagerDutyApiRef = createApiRef<PagerDutyApi>({
id: 'plugin.pagerduty.api',
});
const commonGetServiceParams =
'time_zone=UTC&include[]=integrations&include[]=escalation_policies';
export class PagerDutyClient implements PagerDutyApi {
static fromConfig(
configApi: ConfigApi,
discoveryApi: DiscoveryApi,
identityApi: IdentityApi,
{ discoveryApi, fetchApi }: PagerDutyClientApiDependencies,
) {
const eventsBaseUrl: string =
configApi.getOptionalString('pagerDuty.eventsBaseUrl') ??
'https://events.pagerduty.com/v2';
return new PagerDutyClient({
eventsBaseUrl,
discoveryApi,
identityApi,
fetchApi,
});
}
constructor(private readonly config: ClientApiConfig) {}
constructor(private readonly config: PagerDutyClientApiConfig) {}
async getServiceByIntegrationKey(integrationKey: string): Promise<Service[]> {
const params = `time_zone=UTC&include[]=integrations&include[]=escalation_policies&query=${integrationKey}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/services?${params}`;
const { services } = await this.getByUrl<ServicesResponse>(url);
async getServiceByEntity(entity: Entity): Promise<PagerDutyServiceResponse> {
const { integrationKey, serviceId } = getPagerDutyEntity(entity);
return services;
let response: PagerDutyServiceResponse;
let url: string;
if (integrationKey) {
url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/services?${commonGetServiceParams}&query=${integrationKey}`;
const { services } = await this.getByUrl<PagerDutyServicesResponse>(url);
const service = services[0];
if (!service) throw new NotFoundError();
response = { service };
} else if (serviceId) {
url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/services/${serviceId}?${commonGetServiceParams}`;
response = await this.getByUrl<PagerDutyServiceResponse>(url);
} else {
throw new NotFoundError();
}
return response;
}
async getIncidentsByServiceId(serviceId: string): Promise<Incident[]> {
async getIncidentsByServiceId(
serviceId: string,
): Promise<PagerDutyIncidentsResponse> {
const params = `time_zone=UTC&sort_by=created_at&statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${serviceId}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/incidents?${params}`;
const { incidents } = await this.getByUrl<IncidentsResponse>(url);
return incidents;
return await this.getByUrl<PagerDutyIncidentsResponse>(url);
}
async getChangeEventsByServiceId(serviceId: string): Promise<ChangeEvent[]> {
async getChangeEventsByServiceId(
serviceId: string,
): Promise<PagerDutyChangeEventsResponse> {
const params = `limit=5&time_zone=UTC&sort_by=timestamp`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/services/${serviceId}/change_events?${params}`;
const { change_events } = await this.getByUrl<ChangeEventsResponse>(url);
return change_events;
return await this.getByUrl<PagerDutyChangeEventsResponse>(url);
}
async getOnCallByPolicyId(policyId: string): Promise<OnCall[]> {
async getOnCallByPolicyId(
policyId: string,
): Promise<PagerDutyOnCallsResponse> {
const params = `time_zone=UTC&include[]=users&escalation_policy_ids[]=${policyId}`;
const url = `${await this.config.discoveryApi.getBaseUrl(
'proxy',
)}/pagerduty/oncalls?${params}`;
const { oncalls } = await this.getByUrl<OnCallsResponse>(url);
return oncalls;
return await this.getByUrl<PagerDutyOnCallsResponse>(url);
}
triggerAlarm(request: TriggerAlarmRequest): Promise<Response> {
triggerAlarm(request: PagerDutyTriggerAlarmRequest): Promise<Response> {
const { integrationKey, source, description, userName } = request;
const body = JSON.stringify({
@@ -130,13 +153,11 @@ export class PagerDutyClient implements PagerDutyApi {
}
private async getByUrl<T>(url: string): Promise<T> {
const { token: idToken } = await this.config.identityApi.getCredentials();
const options = {
method: 'GET',
headers: {
Accept: 'application/vnd.pagerduty+json;version=2',
'Content-Type': 'application/json',
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
};
const response = await this.request(url, options);
@@ -147,10 +168,15 @@ export class PagerDutyClient implements PagerDutyApi {
url: string,
options: RequestOptions,
): Promise<Response> {
const response = await fetch(url, options);
const response = await this.config.fetchApi.fetch(url, options);
if (response.status === 401) {
throw new UnauthorizedError();
}
if (response.status === 404) {
throw new NotFoundError();
}
if (!response.ok) {
const payload = await response.json();
const errors = payload.errors.map((error: string) => error).join(' ');
+9 -1
View File
@@ -15,4 +15,12 @@
*/
export { PagerDutyClient, pagerDutyApiRef, UnauthorizedError } from './client';
export type { PagerDutyApi } from './types';
export type {
PagerDutyServiceResponse,
PagerDutyIncidentsResponse,
PagerDutyChangeEventsResponse,
PagerDutyOnCallsResponse,
PagerDutyTriggerAlarmRequest,
PagerDutyClientApiDependencies,
PagerDutyClientApiConfig,
} from './types';
+45 -28
View File
@@ -14,10 +14,36 @@
* limitations under the License.
*/
import { Incident, ChangeEvent, OnCall, Service } from '../components/types';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import {
PagerDutyIncident,
PagerDutyChangeEvent,
PagerDutyOnCall,
PagerDutyService,
} from '../components/types';
import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
export type TriggerAlarmRequest = {
export type PagerDutyServicesResponse = {
services: PagerDutyService[];
};
export type PagerDutyServiceResponse = {
service: PagerDutyService;
};
export type PagerDutyIncidentsResponse = {
incidents: PagerDutyIncident[];
};
export type PagerDutyChangeEventsResponse = {
change_events: PagerDutyChangeEvent[];
};
export type PagerDutyOnCallsResponse = {
oncalls: PagerDutyOnCall[];
};
export type PagerDutyTriggerAlarmRequest = {
integrationKey: string;
source: string;
description: string;
@@ -26,55 +52,46 @@ export type TriggerAlarmRequest = {
export interface PagerDutyApi {
/**
* Fetches a list of services, filtered by the provided integration key.
* Fetches the service for the provided Entity.
*
*/
getServiceByIntegrationKey(integrationKey: string): Promise<Service[]>;
getServiceByEntity(entity: Entity): Promise<PagerDutyServiceResponse>;
/**
* Fetches a list of incidents a provided service has.
*
*/
getIncidentsByServiceId(serviceId: string): Promise<Incident[]>;
getIncidentsByServiceId(
serviceId: string,
): Promise<PagerDutyIncidentsResponse>;
/**
* Fetches a list of change events a provided service has.
*
*/
getChangeEventsByServiceId(serviceId: string): Promise<ChangeEvent[]>;
getChangeEventsByServiceId(
serviceId: string,
): Promise<PagerDutyChangeEventsResponse>;
/**
* Fetches the list of users in an escalation policy.
*
*/
getOnCallByPolicyId(policyId: string): Promise<OnCall[]>;
getOnCallByPolicyId(policyId: string): Promise<PagerDutyOnCallsResponse>;
/**
* Triggers an incident to whoever is on-call.
*/
triggerAlarm(request: TriggerAlarmRequest): Promise<Response>;
triggerAlarm(request: PagerDutyTriggerAlarmRequest): Promise<Response>;
}
export type ServicesResponse = {
services: Service[];
};
export type IncidentsResponse = {
incidents: Incident[];
};
export type ChangeEventsResponse = {
change_events: ChangeEvent[];
};
export type OnCallsResponse = {
oncalls: OnCall[];
};
export type ClientApiConfig = {
eventsBaseUrl?: string;
export type PagerDutyClientApiDependencies = {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
fetchApi: FetchApi;
};
export type PagerDutyClientApiConfig = PagerDutyClientApiDependencies & {
eventsBaseUrl?: string;
};
export type RequestOptions = {
@@ -26,7 +26,7 @@ import {
Typography,
} from '@material-ui/core';
import { DateTime, Duration } from 'luxon';
import { ChangeEvent } from '../types';
import { PagerDutyChangeEvent } from '../types';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { BackstageTheme } from '@backstage/theme';
@@ -44,7 +44,7 @@ const useStyles = makeStyles<BackstageTheme>({
});
type Props = {
changeEvent: ChangeEvent;
changeEvent: PagerDutyChangeEvent;
};
export const ChangeEventListItem = ({ changeEvent }: Props) => {
@@ -54,7 +54,7 @@ export const ChangeEventListItem = ({ changeEvent }: Props) => {
const changedAt = DateTime.local()
.minus(Duration.fromMillis(duration))
.toRelative({ locale: 'en' });
let externalLinkElem = null;
let externalLinkElem: JSX.Element | undefined;
if (changeEvent.links.length > 0) {
const text: string = changeEvent.links[0].text;
externalLinkElem = (
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { ChangeEvent } from '../types';
import { PagerDutyChangeEvent } from '../types';
import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
import { pagerDutyApiRef } from '../../api';
import { ApiProvider } from '@backstage/core-app-api';
@@ -30,7 +30,7 @@ describe('Incidents', () => {
it('Renders an empty state when there are no change events', async () => {
mockPagerDutyApi.getChangeEventsByServiceId = jest
.fn()
.mockImplementationOnce(async () => []);
.mockImplementationOnce(async () => ({ change_events: [] }));
const { getByText, queryByTestId } = render(
wrapInTestApp(
@@ -46,37 +46,36 @@ describe('Incidents', () => {
it('Renders all change events', async () => {
mockPagerDutyApi.getChangeEventsByServiceId = jest
.fn()
.mockImplementationOnce(
async () =>
[
{
id: 'id1',
source: 'changeSource1',
html_url: 'www.pdlink.com',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'summary of event',
timestamp: '2020-07-17T08:42:58.315+0000',
},
{
id: 'id2',
source: 'changeSource1',
html_url: 'www.pdlink.com/link',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'sum of EVENT',
timestamp: '2020-07-18T08:42:58.315+0000',
},
] as ChangeEvent[],
);
.mockImplementationOnce(async () => ({
change_events: [
{
id: 'id1',
source: 'changeSource1',
html_url: 'www.pdlink.com',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'summary of event',
timestamp: '2020-07-17T08:42:58.315+0000',
},
{
id: 'id2',
source: 'changeSource1',
html_url: 'www.pdlink.com/link',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'sum of EVENT',
timestamp: '2020-07-18T08:42:58.315+0000',
},
] as PagerDutyChangeEvent[],
}));
const { getByText, getAllByTitle, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
@@ -95,36 +94,35 @@ describe('Incidents', () => {
it('Does not render a pagerduty link when html_url is not present in response', async () => {
mockPagerDutyApi.getChangeEventsByServiceId = jest
.fn()
.mockImplementationOnce(
async () =>
[
{
id: 'id1',
source: 'changeSource1',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'summary of event',
timestamp: '2020-07-17T08:42:58.315+0000',
},
{
id: 'id2',
source: 'changeSource1',
html_url: 'www.pdlink.com/link',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'sum of EVENT',
timestamp: '2020-07-18T08:42:58.315+0000',
},
] as ChangeEvent[],
);
.mockImplementationOnce(async () => ({
change_events: [
{
id: 'id1',
source: 'changeSource1',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'summary of event',
timestamp: '2020-07-17T08:42:58.315+0000',
},
{
id: 'id2',
source: 'changeSource1',
html_url: 'www.pdlink.com/link',
links: [
{
href: 'www.externalLink1.com',
text: 'link1',
},
],
summary: 'sum of EVENT',
timestamp: '2020-07-18T08:42:58.315+0000',
},
] as PagerDutyChangeEvent[],
}));
const { getByText, getAllByTitle, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
@@ -33,7 +33,10 @@ export const ChangeEvents = ({ serviceId, refreshEvents }: Props) => {
const api = useApi(pagerDutyApiRef);
const [{ value: changeEvents, loading, error }, getChangeEvents] = useAsyncFn(
async () => await api.getChangeEventsByServiceId(serviceId),
async () => {
const { change_events } = await api.getChangeEventsByServiceId(serviceId);
return change_events;
},
);
useEffect(() => {
@@ -0,0 +1,35 @@
/*
* Copyright 2020 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 { Button } from '@material-ui/core';
import { EmptyState } from '@backstage/core-components';
export const ServiceNotFoundError = () => (
<EmptyState
missing="data"
title="PagerDuty Service Not Found"
description="A service could not be found within PagerDuty based on the provided annotations. Please verify your annotation details."
action={
<Button
color="primary"
variant="contained"
href="https://github.com/backstage/backstage/blob/master/plugins/pagerduty/README.md"
>
Read More
</Button>
}
/>
);
@@ -15,3 +15,4 @@
*/
export { MissingTokenError } from './MissingTokenError';
export { ServiceNotFoundError } from './ServiceNotFoundError';
@@ -17,7 +17,7 @@ import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { EscalationPolicy } from './EscalationPolicy';
import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
import { User } from '../types';
import { PagerDutyUser } from '../types';
import { pagerDutyApiRef } from '../../api';
import { ApiProvider } from '@backstage/core-app-api';
@@ -30,7 +30,7 @@ describe('Escalation', () => {
it('Handles an empty response', async () => {
mockPagerDutyApi.getOnCallByPolicyId = jest
.fn()
.mockImplementationOnce(async () => []);
.mockImplementationOnce(async () => ({ oncalls: [] }));
const { getByText, queryByTestId } = render(
wrapInTestApp(
@@ -48,17 +48,19 @@ describe('Escalation', () => {
it('Render a list of users', async () => {
mockPagerDutyApi.getOnCallByPolicyId = jest
.fn()
.mockImplementationOnce(async () => [
{
user: {
name: 'person1',
id: 'p1',
summary: 'person1',
email: 'person1@example.com',
html_url: 'http://a.com/id1',
} as User,
},
]);
.mockImplementationOnce(async () => ({
oncalls: [
{
user: {
name: 'person1',
id: 'p1',
summary: 'person1',
email: 'person1@example.com',
html_url: 'http://a.com/id1',
} as PagerDutyUser,
},
],
}));
const { getByText, queryByTestId } = render(
wrapInTestApp(
@@ -37,7 +37,7 @@ export const EscalationPolicy = ({ policyId }: Props) => {
loading,
error,
} = useAsync(async () => {
const oncalls = await api.getOnCallByPolicyId(policyId);
const { oncalls } = await api.getOnCallByPolicyId(policyId);
const usersItem = oncalls
.sort((a, b) => a.escalation_level - b.escalation_level)
.map(oncall => oncall.user);
@@ -28,7 +28,7 @@ import {
import Avatar from '@material-ui/core/Avatar';
import EmailIcon from '@material-ui/icons/Email';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { User } from '../types';
import { PagerDutyUser } from '../types';
const useStyles = makeStyles({
listItemPrimary: {
@@ -37,7 +37,7 @@ const useStyles = makeStyles({
});
type Props = {
user: User;
user: PagerDutyUser;
};
export const EscalationUser = ({ user }: Props) => {
@@ -29,7 +29,7 @@ import {
import Done from '@material-ui/icons/Done';
import Warning from '@material-ui/icons/Warning';
import { DateTime, Duration } from 'luxon';
import { Incident } from '../types';
import { PagerDutyIncident } from '../types';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import { BackstageTheme } from '@backstage/theme';
@@ -61,7 +61,7 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
}));
type Props = {
incident: Incident;
incident: PagerDutyIncident;
};
export const IncidentListItem = ({ incident }: Props) => {
@@ -18,7 +18,7 @@ import { render, waitFor } from '@testing-library/react';
import { Incidents } from './Incidents';
import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
import { pagerDutyApiRef } from '../../api';
import { Incident } from '../types';
import { PagerDutyIncident } from '../types';
import { ApiProvider } from '@backstage/core-app-api';
const mockPagerDutyApi = {
@@ -30,7 +30,7 @@ describe('Incidents', () => {
it('Renders an empty state when there are no incidents', async () => {
mockPagerDutyApi.getIncidentsByServiceId = jest
.fn()
.mockImplementationOnce(async () => []);
.mockImplementationOnce(async () => ({ incidents: [] }));
const { getByText, queryByTestId } = render(
wrapInTestApp(
@@ -44,9 +44,10 @@ describe('Incidents', () => {
});
it('Renders all incidents', async () => {
mockPagerDutyApi.getIncidentsByServiceId = jest.fn().mockImplementationOnce(
async () =>
[
mockPagerDutyApi.getIncidentsByServiceId = jest
.fn()
.mockImplementationOnce(async () => ({
incidents: [
{
id: 'id1',
status: 'triggered',
@@ -82,8 +83,8 @@ describe('Incidents', () => {
html_url: 'http://a.com/id2',
serviceId: 'sId2',
},
] as Incident[],
);
] as PagerDutyIncident[],
}));
const { getByText, getAllByTitle, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
@@ -34,7 +34,12 @@ export const Incidents = ({ serviceId, refreshIncidents }: Props) => {
const api = useApi(pagerDutyApiRef);
const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn(
async () => await api.getIncidentsByServiceId(serviceId),
async () => {
const { incidents: foundIncidents } = await api.getIncidentsByServiceId(
serviceId,
);
return foundIncidents;
},
);
useEffect(() => {
@@ -15,26 +15,17 @@
*/
import React from 'react';
import { render, waitFor, fireEvent, act } from '@testing-library/react';
import { PagerDutyCard } from '../PagerDutyCard';
import { PagerDutyCard, isPluginApplicableToEntity } from '../PagerDutyCard';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { NotFoundError } from '@backstage/errors';
import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils';
import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../../api';
import { Service, User } from '../types';
import { PagerDutyService, PagerDutyUser } from '../types';
import { alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
const mockPagerDutyApi: Partial<PagerDutyClient> = {
getServiceByIntegrationKey: async () => [],
getOnCallByPolicyId: async () => [],
getIncidentsByServiceId: async () => [],
};
const apis = TestApiRegistry.from(
[pagerDutyApiRef, mockPagerDutyApi],
[alertApiRef, {}],
);
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
@@ -46,7 +37,39 @@ const entity: Entity = {
},
};
const user: User = {
const entityWithoutAnnotations: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {},
},
};
const entityWithServiceId: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/service-id': 'def456',
},
},
};
const entityWithAllAnnotations: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
'pagerduty.com/service-id': 'def456',
},
},
};
const user: PagerDutyUser = {
name: 'person1',
id: 'p1',
summary: 'person1',
@@ -54,8 +77,8 @@ const user: User = {
html_url: 'http://a.com/id1',
};
const service: Service = {
id: 'abc',
const service: PagerDutyService = {
id: 'def456',
name: 'pagerduty-name',
html_url: 'www.example.com',
escalation_policy: {
@@ -63,14 +86,51 @@ const service: Service = {
user: user,
html_url: 'http://a.com/id1',
},
integrationKey: 'abcd',
integrationKey: 'abc123',
};
const mockPagerDutyApi: Partial<PagerDutyClient> = {
getServiceByEntity: async () => ({ service }),
getOnCallByPolicyId: async () => ({ oncalls: [] }),
getIncidentsByServiceId: async () => ({ incidents: [] }),
};
const apis = TestApiRegistry.from(
[pagerDutyApiRef, mockPagerDutyApi],
[alertApiRef, {}],
);
describe('isPluginApplicableToEntity', () => {
describe('when entity has no annotations', () => {
it('returns false', () => {
expect(isPluginApplicableToEntity(entityWithoutAnnotations)).toBe(false);
});
});
describe('when entity has the pagerduty.com/integration-key annotation', () => {
it('returns true', () => {
expect(isPluginApplicableToEntity(entity)).toBe(true);
});
});
describe('when entity has the pagerduty.com/service-id annotation', () => {
it('returns true', () => {
expect(isPluginApplicableToEntity(entityWithServiceId)).toBe(true);
});
});
describe('when entity has all annotations', () => {
it('returns true', () => {
expect(isPluginApplicableToEntity(entityWithAllAnnotations)).toBe(true);
});
});
});
describe('PageDutyCard', () => {
it('Render pagerduty', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockImplementationOnce(async () => [service]);
.mockImplementationOnce(async () => ({ service }));
const { getByText, queryByTestId } = render(
wrapInTestApp(
@@ -89,7 +149,7 @@ describe('PageDutyCard', () => {
});
it('Handles custom error for missing token', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockRejectedValueOnce(new UnauthorizedError());
@@ -106,8 +166,26 @@ describe('PageDutyCard', () => {
expect(getByText('Missing or invalid PagerDuty Token')).toBeInTheDocument();
});
it('Handles custom NotFoundError', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockRejectedValueOnce(new NotFoundError());
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('PagerDuty Service Not Found')).toBeInTheDocument();
});
it('handles general error', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockRejectedValueOnce(new Error('An error occurred'));
const { getByText, queryByTestId } = render(
@@ -127,10 +205,11 @@ describe('PageDutyCard', () => {
),
).toBeInTheDocument();
});
it('opens the dialog when trigger button is clicked', async () => {
mockPagerDutyApi.getServiceByIntegrationKey = jest
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockImplementationOnce(async () => [service]);
.mockImplementationOnce(async () => ({ service }));
const { getByText, queryByTestId, getByRole } = render(
wrapInTestApp(
@@ -150,4 +229,131 @@ describe('PageDutyCard', () => {
});
expect(getByRole('dialog')).toBeInTheDocument();
});
describe('when entity has the pagerduty.com/service-id annotation', () => {
it('Renders PagerDuty service information', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockImplementationOnce(async () => ({ service }));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entityWithServiceId}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Service Directory')).toBeInTheDocument();
expect(getByText('Create Incident')).toBeInTheDocument();
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
expect(getByText('Empty escalation policy')).toBeInTheDocument();
});
it('Handles custom error for missing token', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockRejectedValueOnce(new UnauthorizedError());
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entityWithServiceId}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText('Missing or invalid PagerDuty Token'),
).toBeInTheDocument();
});
it('Handles custom NotFoundError', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockRejectedValueOnce(new NotFoundError());
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entityWithServiceId}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('PagerDuty Service Not Found')).toBeInTheDocument();
});
it('handles general error', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockRejectedValueOnce(new Error('An error occurred'));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entityWithServiceId}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByText(
'Error encountered while fetching information. An error occurred',
),
).toBeInTheDocument();
});
it('disables the Create Incident button', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockImplementationOnce(async () => ({ service }));
const { queryByTestId, getByTitle } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entityWithServiceId}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(
getByTitle('Must provide an integration-key to create incidents')
.className,
).toMatch('disabled');
});
});
describe('when entity has all annotations', () => {
it('queries by integration key', async () => {
mockPagerDutyApi.getServiceByEntity = jest
.fn()
.mockImplementationOnce(async () => ({ service }));
const { getByText, queryByTestId } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entityWithAllAnnotations}>
<PagerDutyCard />
</EntityProvider>
</ApiProvider>,
),
);
await waitFor(() => !queryByTestId('progress'));
expect(getByText('Service Directory')).toBeInTheDocument();
expect(getByText('Create Incident')).toBeInTheDocument();
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
expect(getByText('Empty escalation policy')).toBeInTheDocument();
});
});
});
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useCallback } from 'react';
import React, { ReactNode, useState, useCallback } from 'react';
import { Entity } from '@backstage/catalog-model';
import { Card, CardHeader, Divider, CardContent } from '@material-ui/core';
import { Incidents } from '../Incident';
@@ -22,28 +22,39 @@ import useAsync from 'react-use/lib/useAsync';
import { Alert } from '@material-ui/lab';
import { pagerDutyApiRef, UnauthorizedError } from '../../api';
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
import { MissingTokenError } from '../Errors/MissingTokenError';
import { MissingTokenError, ServiceNotFoundError } from '../Errors';
import WebIcon from '@material-ui/icons/Web';
import DateRangeIcon from '@material-ui/icons/DateRange';
import { usePagerdutyEntity } from '../../hooks';
import { PAGERDUTY_INTEGRATION_KEY } from '../constants';
import { PAGERDUTY_INTEGRATION_KEY, PAGERDUTY_SERVICE_ID } from '../constants';
import { TriggerDialog } from '../TriggerDialog';
import { ChangeEvents } from '../ChangeEvents';
import { useApi } from '@backstage/core-plugin-api';
import { NotFoundError } from '@backstage/errors';
import {
Progress,
HeaderIconLinkRow,
IconLinkVerticalProps,
TabbedCard,
CardTab,
InfoCard,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { getPagerDutyEntity } from '../pagerDutyEntity';
const BasicCard = ({ children }: { children: ReactNode }) => (
<InfoCard title="PagerDuty">{children}</InfoCard>
);
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]);
Boolean(
entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY] ||
entity.metadata.annotations?.[PAGERDUTY_SERVICE_ID],
);
export const PagerDutyCard = () => {
const { integrationKey } = usePagerdutyEntity();
const { entity } = useEntity();
const pagerDutyEntity = getPagerDutyEntity(entity);
const api = useApi(pagerDutyApiRef);
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
const [refreshChangeEvents, setRefreshChangeEvents] =
@@ -67,33 +78,44 @@ export const PagerDutyCard = () => {
loading,
error,
} = useAsync(async () => {
const services = await api.getServiceByIntegrationKey(
integrationKey as string,
);
const { service: foundService } = await api.getServiceByEntity(entity);
return {
id: services[0].id,
name: services[0].name,
url: services[0].html_url,
policyId: services[0].escalation_policy.id,
policyLink: services[0].escalation_policy.html_url,
id: foundService.id,
name: foundService.name,
url: foundService.html_url,
policyId: foundService.escalation_policy.id,
policyLink: foundService.escalation_policy.html_url,
};
});
if (error instanceof UnauthorizedError) {
return <MissingTokenError />;
}
if (error) {
return (
<Alert severity="error">
Error encountered while fetching information. {error.message}
</Alert>
);
let errorNode: ReactNode;
switch (error.constructor) {
case UnauthorizedError:
errorNode = <MissingTokenError />;
break;
case NotFoundError:
errorNode = <ServiceNotFoundError />;
break;
default:
errorNode = (
<Alert severity="error">
Error encountered while fetching information. {error.message}
</Alert>
);
}
return <BasicCard>{errorNode}</BasicCard>;
}
if (loading) {
return <Progress />;
return (
<BasicCard>
<Progress />
</BasicCard>
);
}
const serviceLink: IconLinkVerticalProps = {
@@ -102,11 +124,21 @@ export const PagerDutyCard = () => {
icon: <WebIcon />,
};
/**
* In order to create incidents using the REST API, a valid user email address must be present.
* There is no guarantee the current user entity has a valid email association, so instead just
* only allow triggering incidents when an integration key is present.
*/
const createIncidentDisabled = !pagerDutyEntity.integrationKey;
const triggerLink: IconLinkVerticalProps = {
label: 'Create Incident',
onClick: showDialog,
icon: <AlarmAddIcon />,
color: 'secondary',
disabled: createIncidentDisabled,
title: createIncidentDisabled
? 'Must provide an integration-key to create incidents'
: '',
};
const escalationPolicyLink: IconLinkVerticalProps = {
@@ -145,12 +177,14 @@ export const PagerDutyCard = () => {
<EscalationPolicy policyId={service!.policyId} />
</CardContent>
</Card>
<TriggerDialog
data-testid="trigger-dialog"
showDialog={dialogShown}
handleDialog={hideDialog}
onIncidentCreated={handleRefresh}
/>
{!createIncidentDisabled && (
<TriggerDialog
data-testid="trigger-dialog"
showDialog={dialogShown}
handleDialog={hideDialog}
onIncidentCreated={handleRefresh}
/>
)}
</>
);
};
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export const PAGERDUTY_INTEGRATION_KEY = 'pagerduty.com/integration-key';
export const PAGERDUTY_SERVICE_ID = 'pagerduty.com/service-id';
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright 2020 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 type {
PagerDutyChangeEvent,
PagerDutyIncident,
PagerDutyService,
PagerDutyOnCall,
PagerDutyAssignee,
PagerDutyUser,
} from './types';
export {
isPluginApplicableToEntity,
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from './PagerDutyCard';
export { TriggerButton } from './TriggerButton';
@@ -0,0 +1,98 @@
/*
* Copyright 2020 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 { getPagerDutyEntity } from './pagerDutyEntity';
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
},
},
};
const entityWithoutAnnotations: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {},
},
};
const entityWithServiceId: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/service-id': 'def456',
},
},
};
const entityWithAllAnnotations: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'pagerduty-test',
annotations: {
'pagerduty.com/integration-key': 'abc123',
'pagerduty.com/service-id': 'def456',
},
},
};
describe('getPagerDutyEntity', () => {
describe('when entity has no annotations', () => {
it('returns a PagerDutyEntity', () => {
expect(getPagerDutyEntity(entityWithoutAnnotations)).toEqual({
name: 'pagerduty-test',
});
});
});
describe('when entity has integration key annotation', () => {
it('returns a PagerDutyEntity', () => {
expect(getPagerDutyEntity(entity)).toEqual({
name: 'pagerduty-test',
integrationKey: 'abc123',
});
});
});
describe('when entity has service id annotation', () => {
it('returns a PagerDutyEntity', () => {
expect(getPagerDutyEntity(entityWithServiceId)).toEqual({
name: 'pagerduty-test',
serviceId: 'def456',
});
});
});
describe('when entity has all annotations', () => {
it('returns a PagerDutyEntity', () => {
expect(getPagerDutyEntity(entityWithAllAnnotations)).toEqual({
name: 'pagerduty-test',
integrationKey: 'abc123',
serviceId: 'def456',
});
});
});
});
@@ -0,0 +1,29 @@
/*
* Copyright 2020 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 { PagerDutyEntity } from '../types';
import { PAGERDUTY_INTEGRATION_KEY, PAGERDUTY_SERVICE_ID } from './constants';
export function getPagerDutyEntity(entity: Entity): PagerDutyEntity {
const {
[PAGERDUTY_INTEGRATION_KEY]: integrationKey,
[PAGERDUTY_SERVICE_ID]: serviceId,
} = entity.metadata.annotations || ({} as Record<string, string>);
const name = entity.metadata.name;
return { integrationKey, serviceId, name };
}
+10 -10
View File
@@ -14,11 +14,11 @@
* limitations under the License.
*/
export type ChangeEvent = {
export type PagerDutyChangeEvent = {
id: string;
integration: [
{
service: Service;
service: PagerDutyService;
},
];
source: string;
@@ -33,44 +33,44 @@ export type ChangeEvent = {
timestamp: string;
};
export type Incident = {
export type PagerDutyIncident = {
id: string;
title: string;
status: string;
html_url: string;
assignments: [
{
assignee: Assignee;
assignee: PagerDutyAssignee;
},
];
serviceId: string;
created_at: string;
};
export type Service = {
export type PagerDutyService = {
id: string;
name: string;
html_url: string;
integrationKey: string;
escalation_policy: {
id: string;
user: User;
user: PagerDutyUser;
html_url: string;
};
};
export type OnCall = {
user: User;
export type PagerDutyOnCall = {
user: PagerDutyUser;
escalation_level: number;
};
export type Assignee = {
export type PagerDutyAssignee = {
id: string;
summary: string;
html_url: string;
};
export type User = {
export type PagerDutyUser = {
id: string;
summary: string;
email: string;
+4 -7
View File
@@ -15,14 +15,11 @@
*/
import { useEntity } from '@backstage/plugin-catalog-react';
import { PagerDutyEntity } from '../types';
import { getPagerDutyEntity } from '../components/pagerDutyEntity';
import { PAGERDUTY_INTEGRATION_KEY } from '../components/constants';
export function usePagerdutyEntity() {
export function usePagerdutyEntity(): PagerDutyEntity {
const { entity } = useEntity();
const integrationKey =
entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY];
const name = entity.metadata.name;
return { integrationKey, name };
return getPagerDutyEntity(entity);
}
+5 -11
View File
@@ -25,14 +25,8 @@ export {
pagerDutyPlugin as plugin,
EntityPagerDutyCard,
} from './plugin';
export {
isPluginApplicableToEntity,
isPluginApplicableToEntity as isPagerDutyAvailable,
PagerDutyCard,
} from './components/PagerDutyCard';
export { TriggerButton } from './components/TriggerButton';
export {
PagerDutyClient,
pagerDutyApiRef,
UnauthorizedError,
} from './api/client';
export * from './components';
export * from './api';
export type { PagerDutyEntity } from './types';
+4 -4
View File
@@ -19,9 +19,9 @@ import {
createPlugin,
createRouteRef,
discoveryApiRef,
fetchApiRef,
configApiRef,
createComponentExtension,
identityApiRef,
} from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
@@ -36,10 +36,10 @@ export const pagerDutyPlugin = createPlugin({
deps: {
discoveryApi: discoveryApiRef,
configApi: configApiRef,
identityApi: identityApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, identityApi }) =>
PagerDutyClient.fromConfig(configApi, discoveryApi, identityApi),
factory: ({ configApi, discoveryApi, fetchApi }) =>
PagerDutyClient.fromConfig(configApi, { discoveryApi, fetchApi }),
}),
],
});
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright 2020 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 type PagerDutyEntity = {
integrationKey?: string;
serviceId?: string;
name: string;
};