From 704959ff9cb6305b5dfdda33d99c35a55b662671 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Wed, 30 Nov 2022 01:19:01 +0000 Subject: [PATCH] feat(events,catalog/github): handle github team.edited and membership events Signed-off-by: Rogerio Angeliski --- .../api-report.md | 8 + .../src/lib/defaultTransformers.ts | 1 + .../src/lib/github.test.ts | 10 + .../src/lib/github.ts | 2 + .../providers/GithubOrgEntityProvider.test.ts | 483 +++++++++++++++++- .../src/providers/GithubOrgEntityProvider.ts | 218 +++++++- 6 files changed, 712 insertions(+), 10 deletions(-) diff --git a/plugins/catalog-backend-module-github/api-report.md b/plugins/catalog-backend-module-github/api-report.md index 590b7d2d6b..4cb70cf065 100644 --- a/plugins/catalog-backend-module-github/api-report.md +++ b/plugins/catalog-backend-module-github/api-report.md @@ -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; @@ -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; diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts index efd3eedd5d..3563b4cbc4 100644 --- a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts @@ -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) { diff --git a/plugins/catalog-backend-module-github/src/lib/github.test.ts b/plugins/catalog-backend-module-github/src/lib/github.test.ts index d07d2f6af8..fa79c03903 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.test.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.test.ts @@ -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: { diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts index 4d583d0730..f1d0acc068 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.ts @@ -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 diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts index 2f6376cb09..2775da7228 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts @@ -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 = { + getToken: jest.fn(), + authenticate: jest.fn(), +}; + +const mockCatalogApi = { + getEntityByRef: jest.fn(), + getEntities: jest.fn(), +}; +const catalogApi = mockCatalogApi as Partial 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, + }, + ], + }); + }); }); }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts index 5de931286d..e605b75956 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts @@ -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; + 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;