feat(events,catalog/github): handle github team.edited and membership events

Signed-off-by: Rogerio Angeliski <angeliski@hotmail.com>
This commit is contained in:
Rogerio Angeliski
2022-11-30 01:19:01 +00:00
parent b9dc05b91e
commit 704959ff9c
6 changed files with 712 additions and 10 deletions
@@ -5,6 +5,7 @@
```ts
import { AnalyzeOptions } from '@backstage/plugin-catalog-backend';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { CatalogProcessor } from '@backstage/plugin-catalog-backend';
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend';
import { Config } from '@backstage/config';
@@ -188,6 +189,8 @@ export class GithubOrgEntityProvider
githubCredentialsProvider?: GithubCredentialsProvider;
userTransformer?: UserTransformer;
teamTransformer?: TeamTransformer;
catalogApi?: CatalogApi;
tokenManager?: TokenManager;
});
// (undocumented)
connect(connection: EntityProviderConnection): Promise<void>;
@@ -210,12 +213,16 @@ export type GitHubOrgEntityProviderOptions = GithubOrgEntityProviderOptions;
// @public
export interface GithubOrgEntityProviderOptions {
// (undocumented)
catalogApi?: CatalogApi;
githubCredentialsProvider?: GithubCredentialsProvider;
id: string;
logger: Logger;
orgUrl: string;
schedule?: 'manual' | TaskRunner;
teamTransformer?: TeamTransformer;
// (undocumented)
tokenManager?: TokenManager;
userTransformer?: UserTransformer;
}
@@ -246,6 +253,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor {
// @public
export type GithubTeam = {
id: string;
slug: string;
combinedSlug: string;
name?: string;
@@ -88,6 +88,7 @@ export const defaultOrganizationTeamTransformer: TeamTransformer =
async team => {
const annotations: { [annotationName: string]: string } = {
'github.com/team-slug': team.combinedSlug,
'github.com/node-id': team.id,
};
if (team.editTeamUrl) {
@@ -204,6 +204,7 @@ describe('github', () => {
pageInfo: { hasNextPage: false },
nodes: [
{
id: 'node-id-1',
slug: 'team',
combinedSlug: 'blah/team',
name: 'Team',
@@ -211,6 +212,7 @@ describe('github', () => {
avatarUrl: 'http://example.com/team.jpeg',
editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit',
parentTeam: {
id: 'node-id-parent',
slug: 'parent',
combinedSlug: '',
members: [],
@@ -235,6 +237,7 @@ describe('github', () => {
description: 'The one and only team',
annotations: {
'github.com/team-slug': 'blah/team',
'github.com/node-id': 'node-id-1',
'backstage.io/edit-url':
'http://example.com/orgs/blah/teams/team/edit',
},
@@ -304,6 +307,7 @@ describe('github', () => {
pageInfo: { hasNextPage: false },
nodes: [
{
id: 'node-id-1',
slug: 'team',
combinedSlug: 'blah/team',
name: 'Team',
@@ -311,6 +315,7 @@ describe('github', () => {
avatarUrl: 'http://example.com/team.jpeg',
editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit',
parentTeam: {
id: 'node-id-parent',
slug: 'parent',
combinedSlug: '',
members: [],
@@ -369,6 +374,7 @@ describe('github', () => {
pageInfo: { hasNextPage: false },
nodes: [
{
id: 'node-id-1',
slug: 'team',
combinedSlug: 'blah/team',
name: 'Team',
@@ -376,6 +382,7 @@ describe('github', () => {
avatarUrl: 'http://example.com/team.jpeg',
editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit',
parentTeam: {
id: 'node-id-parent',
slug: 'parent',
combinedSlug: '',
members: [],
@@ -386,6 +393,7 @@ describe('github', () => {
},
},
{
id: 'node-id-2',
slug: 'team',
combinedSlug: 'blah/team',
name: 'aa',
@@ -393,6 +401,7 @@ describe('github', () => {
avatarUrl: 'http://example.com/team.jpeg',
editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit',
parentTeam: {
id: 'node-id-parent',
slug: 'parent',
combinedSlug: '',
members: [],
@@ -453,6 +462,7 @@ describe('github', () => {
const input: QueryResponse = {
organization: {
team: {
id: 'node-id-1',
slug: '',
combinedSlug: '',
members: {
@@ -68,6 +68,7 @@ export type GithubUser = {
* @public
*/
export type GithubTeam = {
id: string;
slug: string;
combinedSlug: string;
name?: string;
@@ -182,6 +183,7 @@ export async function getOrganizationTeams(
teams(first: 100, after: $cursor) {
pageInfo { hasNextPage, endCursor }
nodes {
id
slug
combinedSlug
name
@@ -14,8 +14,9 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, TokenManager } from '@backstage/backend-common';
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import {
GithubCredentialsProvider,
GithubIntegrationConfig,
@@ -27,8 +28,19 @@ import {
GithubOrgEntityProvider,
withLocations,
} from './GithubOrgEntityProvider';
import { cloneDeep } from 'lodash';
jest.mock('@octokit/graphql');
const tokenManager: jest.Mocked<TokenManager> = {
getToken: jest.fn(),
authenticate: jest.fn(),
};
const mockCatalogApi = {
getEntityByRef: jest.fn(),
getEntities: jest.fn(),
};
const catalogApi = mockCatalogApi as Partial<CatalogApi> as CatalogApi;
describe('GithubOrgEntityProvider', () => {
describe('read', () => {
@@ -261,6 +273,7 @@ describe('GithubOrgEntityProvider', () => {
const githubCredentialsProvider: GithubCredentialsProvider = {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
@@ -268,6 +281,8 @@ describe('GithubOrgEntityProvider', () => {
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
@@ -348,12 +363,16 @@ describe('GithubOrgEntityProvider', () => {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
githubCredentialsProvider,
orgUrl: 'https://github.com/test-org',
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
@@ -434,12 +453,15 @@ describe('GithubOrgEntityProvider', () => {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
githubCredentialsProvider,
orgUrl: 'https://github.com/test-org',
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
@@ -525,12 +547,15 @@ describe('GithubOrgEntityProvider', () => {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
githubCredentialsProvider,
orgUrl: 'https://github.com/test-org',
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
@@ -595,5 +620,455 @@ describe('GithubOrgEntityProvider', () => {
added: [],
});
});
it('should apply delta on receive a edited team', async () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const logger = getVoidLogger();
const gitHubConfig: GithubIntegrationConfig = {
host: 'github.com',
};
const mockGetCredentials = jest.fn().mockReturnValue({
headers: { token: 'blah' },
type: 'app',
});
const githubCredentialsProvider: GithubCredentialsProvider = {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
githubCredentialsProvider,
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
const event: EventParams = {
topic: 'github.team',
eventPayload: {
action: 'edited',
changes: {
name: {
from: 'mygroup',
},
},
team: {
node_id: 'xpto',
name: 'New Team',
slug: 'new-team',
description: 'description from the new team',
html_url: 'https://github.com/orgs/test-org/teams/new-team',
parent: {
slug: 'father-team',
},
},
organization: {
login: 'test-org',
},
},
};
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/orgs/test-org/teams/mygroup"',
'backstage.io/managed-by-origin-location':
'url:https://github.com/orgs/test-org/teams/mygroup"',
},
name: 'mygroup',
},
spec: {
type: 'team',
children: [],
members: ['user-1'],
},
};
const expectedEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'new-team',
description: 'description from the new team',
annotations: {
'backstage.io/edit-url':
'https://github.com/orgs/test-org/teams/new-team/edit',
'backstage.io/managed-by-location':
'url:https://github.com/orgs/test-org/teams/new-team',
'backstage.io/managed-by-origin-location':
'url:https://github.com/orgs/test-org/teams/new-team',
'github.com/team-slug': 'test-org/new-team',
},
},
spec: {
type: 'team',
children: [],
members: ['user-1'],
profile: {
displayName: 'New Team',
},
},
};
mockCatalogApi.getEntities.mockResolvedValue({
items: [cloneDeep(entity)],
});
await entityProvider.onEvent(event);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'delta',
removed: [
{
locationKey: 'github-org-provider:my-id',
entity: entity,
},
],
added: [
{
locationKey: 'github-org-provider:my-id',
entity: expectedEntity,
},
],
});
});
it('should apply delta on receive a membership added', async () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const logger = getVoidLogger();
const gitHubConfig: GithubIntegrationConfig = {
host: 'github.com',
};
const mockGetCredentials = jest.fn().mockReturnValue({
headers: { token: 'blah' },
type: 'app',
});
const githubCredentialsProvider: GithubCredentialsProvider = {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
githubCredentialsProvider,
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
const event: EventParams = {
topic: 'github.membership',
eventPayload: {
action: 'added',
team: {
name: 'New Team',
slug: 'new-team',
description: 'description from the new team',
html_url: 'https://github.com/orgs/test-org/teams/new-team',
parent: {
slug: 'father-team',
},
},
member: {
login: 'githubuser',
},
organization: {
login: 'test-org',
},
},
};
const groupEntity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'new-team',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/orgs/test-org/teams/new-team',
'backstage.io/managed-by-origin-location':
'url:https://github.com/orgs/test-org/teams/new-team',
},
},
spec: {
type: 'team',
children: [],
members: ['user-1'],
},
};
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: [],
},
};
const expectedGroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'new-team',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/orgs/test-org/teams/new-team',
'backstage.io/managed-by-origin-location':
'url:https://github.com/orgs/test-org/teams/new-team',
},
},
spec: {
type: 'team',
children: [],
members: ['user-1', 'githubuser'],
},
};
const expectedUserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: ['new-team'],
},
};
mockCatalogApi.getEntityByRef
.mockResolvedValueOnce(cloneDeep(groupEntity))
.mockResolvedValueOnce(cloneDeep(userEntity));
await entityProvider.onEvent(event);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'delta',
removed: [
{
locationKey: 'github-org-provider:my-id',
entity: groupEntity,
},
{
locationKey: 'github-org-provider:my-id',
entity: userEntity,
},
],
added: [
{
locationKey: 'github-org-provider:my-id',
entity: expectedGroupEntity,
},
{
locationKey: 'github-org-provider:my-id',
entity: expectedUserEntity,
},
],
});
});
it('should apply delta on receive a membership removed', async () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const logger = getVoidLogger();
const gitHubConfig: GithubIntegrationConfig = {
host: 'github.com',
};
const mockGetCredentials = jest.fn().mockReturnValue({
headers: { token: 'blah' },
type: 'app',
});
const githubCredentialsProvider: GithubCredentialsProvider = {
getCredentials: mockGetCredentials,
};
tokenManager.getToken.mockResolvedValue({ token: 'my-token' });
const entityProvider = new GithubOrgEntityProvider({
id: 'my-id',
githubCredentialsProvider,
orgUrl: 'https://github.com/backstage',
gitHubConfig,
logger,
tokenManager,
catalogApi,
});
entityProvider.connect(entityProviderConnection);
const event: EventParams = {
topic: 'github.membership',
eventPayload: {
action: 'removed',
team: {
name: 'New Team',
slug: 'new-team',
description: 'description from the new team',
html_url: 'https://github.com/orgs/test-org/teams/new-team',
parent: {
slug: 'father-team',
},
},
member: {
login: 'githubuser',
},
organization: {
login: 'test-org',
},
},
};
const groupEntity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'new-team',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/orgs/test-org/teams/new-team',
'backstage.io/managed-by-origin-location':
'url:https://github.com/orgs/test-org/teams/new-team',
},
},
spec: {
type: 'team',
children: [],
members: ['user-1', 'githubuser'],
},
};
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: ['new-team'],
},
};
const expectedGroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'new-team',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/orgs/test-org/teams/new-team',
'backstage.io/managed-by-origin-location':
'url:https://github.com/orgs/test-org/teams/new-team',
},
},
spec: {
type: 'team',
children: [],
members: ['user-1'],
},
};
const expectedUserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'githubuser',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/githubuser',
'backstage.io/managed-by-origin-location':
'url:https://github.com/githubuser',
'github.com/user-login': 'githubuser',
},
},
spec: {
memberOf: [],
},
};
mockCatalogApi.getEntityByRef
.mockResolvedValueOnce(cloneDeep(groupEntity))
.mockResolvedValueOnce(cloneDeep(userEntity));
await entityProvider.onEvent(event);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({
type: 'delta',
removed: [
{
locationKey: 'github-org-provider:my-id',
entity: groupEntity,
},
{
locationKey: 'github-org-provider:my-id',
entity: userEntity,
},
],
added: [
{
locationKey: 'github-org-provider:my-id',
entity: expectedGroupEntity,
},
{
locationKey: 'github-org-provider:my-id',
entity: expectedUserEntity,
},
],
});
});
});
});
@@ -18,7 +18,10 @@ import { TaskRunner } from '@backstage/backend-tasks';
import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
DEFAULT_NAMESPACE,
Entity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
@@ -41,8 +44,12 @@ import {
OrganizationMemberAddedEvent,
OrganizationMemberRemovedEvent,
TeamEvent,
TeamEditedEvent,
MembershipEvent,
} from '@octokit/webhooks-types';
import { merge } from 'lodash';
import { CatalogApi } from '@backstage/catalog-client';
import { TokenManager } from '@backstage/backend-common';
import { merge, omit } from 'lodash';
import * as uuid from 'uuid';
import { Logger } from 'winston';
import {
@@ -115,6 +122,9 @@ export interface GithubOrgEntityProviderOptions {
* Optionally include a team transformer for transforming from GitHub teams to Group Entities
*/
teamTransformer?: TeamTransformer;
catalogApi?: CatalogApi;
tokenManager?: TokenManager;
}
// TODO: Consider supporting an (optional) webhook that reacts on org changes
@@ -130,6 +140,8 @@ export class GithubOrgEntityProvider
private connection?: EntityProviderConnection;
private scheduleFn?: () => Promise<void>;
private eventConfigErrorThrown = false;
static fromConfig(config: Config, options: GithubOrgEntityProviderOptions) {
const integrations = ScmIntegrations.fromConfig(config);
const gitHubConfig = integrations.github.byUrl(options.orgUrl)?.config;
@@ -154,6 +166,8 @@ export class GithubOrgEntityProvider
DefaultGithubCredentialsProvider.fromIntegrations(integrations),
userTransformer: options.userTransformer,
teamTransformer: options.teamTransformer,
catalogApi: options.catalogApi,
tokenManager: options.tokenManager,
});
provider.schedule(options.schedule);
@@ -170,6 +184,8 @@ export class GithubOrgEntityProvider
githubCredentialsProvider?: GithubCredentialsProvider;
userTransformer?: UserTransformer;
teamTransformer?: TeamTransformer;
catalogApi?: CatalogApi;
tokenManager?: TokenManager;
},
) {
this.credentialsProvider =
@@ -247,6 +263,11 @@ export class GithubOrgEntityProvider
const { logger } = this.options;
logger.debug(`Received event from ${params.topic}`);
if (!this.canHandleEvents()) {
logger.debug(`Skiping event ${params.topic}`);
return;
}
const addEntities: DeltaOperationFactory = (org, entities) => ({
removed: [],
added: entities.map(entity => ({
@@ -258,7 +279,6 @@ export class GithubOrgEntityProvider
),
})),
});
const removeEntities: DeltaOperationFactory = (org, entities) => ({
added: [],
removed: entities.map(entity => ({
@@ -271,6 +291,22 @@ export class GithubOrgEntityProvider
})),
});
const replaceEntities: DeltaOperationFactory = (org, entities) => {
const entitiesToReplace = entities.map(entity => ({
locationKey: `github-org-provider:${this.options.id}`,
entity: withLocations(
`https://${this.options.gitHubConfig.host}`,
org,
entity,
),
}));
return {
removed: entitiesToReplace,
added: entitiesToReplace,
};
};
// handle change users in the org https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#organization
if (params.topic.includes('organization')) {
const orgEvent = params.eventPayload as OrganizationEvent;
@@ -283,8 +319,6 @@ export class GithubOrgEntityProvider
}
// handle change teams in the org https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#team
// we don't handle team.edited because isn't possible now to correlate this process when the team name changes
// so the full refresh will be responsible for that operation
if (params.topic.includes('team')) {
const teamEvent = params.eventPayload as TeamEvent;
@@ -292,15 +326,39 @@ export class GithubOrgEntityProvider
await this.onTeamDeltaChangeInOrganization(teamEvent, addEntities);
} else if (teamEvent.action === 'deleted') {
await this.onTeamDeltaChangeInOrganization(teamEvent, removeEntities);
} else if (teamEvent.action === 'edited') {
await this.onTeamEditedInOrganization(teamEvent, replaceEntities);
}
}
// handle change membership in the org https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#membership
if (params.topic.includes('membership')) {
const membershipEvent = params.eventPayload as MembershipEvent;
await this.onMemberChangeToTeam(membershipEvent, replaceEntities);
}
return;
}
/** {@inheritdoc @backstage/plugin-events-node#EventSubscriber.supportsEventTopics} */
supportsEventTopics(): string[] {
return ['github.organization', 'github.team'];
return ['github.organization', 'github.team', 'github.membership'];
}
private canHandleEvents(): boolean {
if (this.options.catalogApi && this.options.tokenManager) {
return true;
}
// throw only once
if (!this.eventConfigErrorThrown) {
this.eventConfigErrorThrown = true;
throw new Error(
`${this.getProviderName()} not well configured to handle ${this.supportsEventTopics()}. Missing CatalogApi and/or TokenManager.`,
);
}
return false;
}
private async onTeamDeltaChangeInOrganization(
@@ -313,7 +371,7 @@ export class GithubOrgEntityProvider
const organizationTeamTransformer =
this.options.teamTransformer || defaultOrganizationTeamTransformer;
const { name, html_url: url, description, slug } = event.team;
const { node_id: id, name, html_url: url, description, slug } = event.team;
const org = event.organization.login;
const { headers } = await this.credentialsProvider.getCredentials({
url: this.options.orgUrl,
@@ -325,6 +383,7 @@ export class GithubOrgEntityProvider
const group = (await organizationTeamTransformer(
{
id,
name,
slug,
editTeamUrl: `${url}/edit`,
@@ -350,6 +409,73 @@ export class GithubOrgEntityProvider
});
}
private async onTeamEditedInOrganization(
event: TeamEditedEvent,
createDeltaOperation: DeltaOperationFactory,
) {
if (!this.connection) {
throw new Error('Not initialized');
}
const { name } = event.changes;
const org = event.organization.login;
const { token } = await this.options.tokenManager!.getToken();
const groups = await this.options.catalogApi!.getEntities(
{
filter: [
{
kind: 'Group',
[`metadata.annotations.github.com/node-id`]: event.team.node_id,
},
],
fields: ['apiVersion', 'kind', 'metadata', 'spec'],
},
{ token },
);
if (groups.items.length === 0) {
this.options.logger.debug(
`Skipping event because couldn't found group with 'github.com/node-id':${event.team.node_id}`,
);
return;
}
const group = groups.items[0] as GroupEntity;
const { removed } = createDeltaOperation(org, [group]);
const { name: newTeamName, html_url: url, description, slug } = event.team;
if (name) {
merge(group, {
metadata: {
name: slug,
annotations: {
[`backstage.io/edit-url`]: `${url}/edit`,
[`github.com/team-slug`]: `${org}/${slug}`,
[ANNOTATION_LOCATION]: `url:${url}`,
[ANNOTATION_ORIGIN_LOCATION]: `url:${url}`,
},
},
spec: {
profile: {
displayName: newTeamName,
},
},
});
}
if (description) {
group.metadata.description = description;
}
const { added } = createDeltaOperation(org, [group]);
await this.connection.applyMutation({
type: 'delta',
removed,
added,
});
}
private async onMemberChangeInOrganization(
event: OrganizationMemberAddedEvent | OrganizationMemberRemovedEvent,
createDeltaOperation: DeltaOperationFactory,
@@ -394,6 +520,86 @@ export class GithubOrgEntityProvider
});
}
private async onMemberChangeToTeam(
event: MembershipEvent,
createDeltaOperation: DeltaOperationFactory,
) {
if (!this.connection) {
throw new Error('Not initialized');
}
// The docs are saying I will receive the slug for the removed event, but the types don't reflect that,
// so I will just check to be sure the slug is there
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#membership
if (!('slug' in event.team)) {
return;
}
const teamSlug = event.team.slug;
const { token } = await this.options.tokenManager!.getToken();
const org = event.organization.login;
let group = (await this.options.catalogApi!.getEntityByRef(
{
kind: 'Group',
namespace: DEFAULT_NAMESPACE,
name: teamSlug,
},
{ token },
)) as GroupEntity;
if (!group) {
this.options.logger.debug(
`Skipping event because couldn't found group ':${event.team.slug} for namespace ${DEFAULT_NAMESPACE}`,
);
return;
}
let user = (await this.options.catalogApi!.getEntityByRef(
{
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: event.member.login,
},
{ token },
)) as UserEntity;
group = omit(group, 'relations');
if (!user) {
this.options.logger.debug(
`Skipping event because couldn't found user ':${event.member.login} for namespace ${DEFAULT_NAMESPACE}`,
);
return;
}
user = omit(user, 'relations');
const { removed } = createDeltaOperation(org, [group, user]);
if (event.action === 'added') {
group.spec?.members?.push(event.member.login);
user.spec?.memberOf?.push(event.team.slug);
} else {
group = merge(omit(group, 'spec.members'), {
spec: {
members: group?.spec?.members?.filter(m => m !== event.member.login),
},
});
user = merge(omit(user, 'spec.memberOf'), {
spec: {
memberOf: user?.spec?.memberOf?.filter(m => m !== teamSlug),
},
});
}
const { added } = createDeltaOperation(org, [group, user]);
await this.connection.applyMutation({
type: 'delta',
removed,
added,
});
}
private schedule(schedule: GithubOrgEntityProviderOptions['schedule']) {
if (!schedule || schedule === 'manual') {
return;