From 8c758fed63fc8399c35ce32bc8f53fd788205711 Mon Sep 17 00:00:00 2001 From: Rogerio Angeliski Date: Mon, 2 Jan 2023 20:00:59 +0000 Subject: [PATCH] refct(events,catalog/github): change membership and team.edit handlers Signed-off-by: Rogerio Angeliski --- .../api-report.md | 1 - .../src/lib/defaultTransformers.ts | 4 - .../src/lib/github.test.ts | 48 ++- .../src/lib/github.ts | 182 ++++++++- .../providers/GithubOrgEntityProvider.test.ts | 371 ++++++++++++++---- .../src/providers/GithubOrgEntityProvider.ts | 177 ++++++++- 6 files changed, 671 insertions(+), 112 deletions(-) diff --git a/plugins/catalog-backend-module-github/api-report.md b/plugins/catalog-backend-module-github/api-report.md index 4b07ffd593..590b7d2d6b 100644 --- a/plugins/catalog-backend-module-github/api-report.md +++ b/plugins/catalog-backend-module-github/api-report.md @@ -246,7 +246,6 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { // @public export type GithubTeam = { - databaseId: number; 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 361942543b..efd3eedd5d 100644 --- a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts @@ -90,10 +90,6 @@ export const defaultOrganizationTeamTransformer: TeamTransformer = 'github.com/team-slug': team.combinedSlug, }; - if (team.databaseId) { - annotations['github.com/team-id'] = `${team.databaseId}`; - } - if (team.editTeamUrl) { annotations['backstage.io/edit-url'] = 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 a9bcf2c49b..387dc8a2ee 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.test.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.test.ts @@ -31,6 +31,7 @@ import { GithubTeam, createAddEntitiesOperation, createRemoveEntitiesOperation, + createReplaceEntitiesOperation, } from './github'; import fetch from 'node-fetch'; @@ -206,7 +207,6 @@ describe('github', () => { pageInfo: { hasNextPage: false }, nodes: [ { - databaseId: 1, slug: 'team', combinedSlug: 'blah/team', name: 'Team', @@ -214,7 +214,6 @@ describe('github', () => { avatarUrl: 'http://example.com/team.jpeg', editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit', parentTeam: { - databaseId: 2, slug: 'parent', combinedSlug: '', members: [], @@ -239,7 +238,6 @@ describe('github', () => { description: 'The one and only team', annotations: { 'github.com/team-slug': 'blah/team', - 'github.com/team-id': '1', 'backstage.io/edit-url': 'http://example.com/orgs/blah/teams/team/edit', }, @@ -309,7 +307,6 @@ describe('github', () => { pageInfo: { hasNextPage: false }, nodes: [ { - databaseId: 1, slug: 'team', combinedSlug: 'blah/team', name: 'Team', @@ -317,7 +314,6 @@ describe('github', () => { avatarUrl: 'http://example.com/team.jpeg', editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit', parentTeam: { - databaseId: 3, slug: 'parent', combinedSlug: '', members: [], @@ -376,7 +372,6 @@ describe('github', () => { pageInfo: { hasNextPage: false }, nodes: [ { - databaseId: 1, slug: 'team', combinedSlug: 'blah/team', name: 'Team', @@ -384,7 +379,6 @@ describe('github', () => { avatarUrl: 'http://example.com/team.jpeg', editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit', parentTeam: { - databaseId: 3, slug: 'parent', combinedSlug: '', members: [], @@ -395,7 +389,6 @@ describe('github', () => { }, }, { - databaseId: 2, slug: 'team', combinedSlug: 'blah/team', name: 'aa', @@ -403,7 +396,6 @@ describe('github', () => { avatarUrl: 'http://example.com/team.jpeg', editTeamUrl: 'http://example.com/orgs/blah/teams/team/edit', parentTeam: { - databaseId: 3, slug: 'parent', combinedSlug: '', members: [], @@ -464,7 +456,6 @@ describe('github', () => { const input: QueryResponse = { organization: { team: { - databaseId: 1, slug: '', combinedSlug: '', members: { @@ -635,4 +626,41 @@ describe('github', () => { }); }); }); + describe('createReplaceEntitiesOperation', () => { + it('create a function to replace deferred entities to a delta operation', () => { + const operation = createReplaceEntitiesOperation('my-id', 'host'); + + 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'], + }, + }; + expect(operation('org', [userEntity])).toEqual({ + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: userEntity, + }, + ], + added: [ + { + locationKey: 'github-org-provider:my-id', + entity: userEntity, + }, + ], + }); + }); + }); }); diff --git a/plugins/catalog-backend-module-github/src/lib/github.ts b/plugins/catalog-backend-module-github/src/lib/github.ts index d33ef4fb34..f719e3d506 100644 --- a/plugins/catalog-backend-module-github/src/lib/github.ts +++ b/plugins/catalog-backend-module-github/src/lib/github.ts @@ -71,7 +71,6 @@ export type GithubUser = { * @public */ export type GithubTeam = { - databaseId: number; slug: string; combinedSlug: string; name?: string; @@ -186,7 +185,6 @@ export async function getOrganizationTeams( teams(first: 100, after: $cursor) { pageInfo { hasNextPage, endCursor } nodes { - databaseId slug combinedSlug name @@ -196,7 +194,14 @@ export async function getOrganizationTeams( parentTeam { slug } members(first: 100, membership: IMMEDIATE) { pageInfo { hasNextPage } - nodes { login } + nodes { + avatarUrl, + bio, + email, + login, + name, + organizationVerifiedDomainEmails(login: $org) + } } } } @@ -243,6 +248,164 @@ export async function getOrganizationTeams( return { groups }; } +export async function getOrganizationTeamsFromUsers( + client: typeof graphql, + org: string, + userLogins: string[], + teamTransformer: TeamTransformer = defaultOrganizationTeamTransformer, +): Promise<{ + groups: GroupEntity[]; +}> { + const query = ` + query teams($org: String!, $cursor: String, $userLogins: [String!] = "") { + organization(login: $org) { + teams(first: 100, after: $cursor, userLogins: $userLogins) { + pageInfo { + hasNextPage + endCursor + } + nodes { + slug + combinedSlug + name + description + avatarUrl + editTeamUrl + parentTeam { + slug + } + members(first: 100, membership: IMMEDIATE) { + pageInfo { + hasNextPage + } + nodes { + avatarUrl, + bio, + email, + login, + name, + organizationVerifiedDomainEmails(login: $org) + } + } + } + } + } +}`; + + const materialisedTeams = async ( + item: GithubTeamResponse, + ctx: TransformerContext, + ): Promise => { + const memberNames: GithubUser[] = []; + + if (!item.members.pageInfo.hasNextPage) { + // We got all the members in one go, run the fast path + for (const user of item.members.nodes) { + memberNames.push(user); + } + } else { + // There were more than a hundred immediate members - run the slow + // path of fetching them explicitly + const { members } = await getTeamMembers(ctx.client, ctx.org, item.slug); + for (const userLogin of members) { + memberNames.push(userLogin); + } + } + + const team: GithubTeam = { + ...item, + members: memberNames, + }; + + return await teamTransformer(team, ctx); + }; + + const groups = await queryWithPaging( + client, + query, + org, + r => r.organization?.teams, + materialisedTeams, + { org, userLogins }, + ); + + return { groups }; +} + +export async function getOrganizationTeam( + client: typeof graphql, + org: string, + teamSlug: string, + teamTransformer: TeamTransformer = defaultOrganizationTeamTransformer, +): Promise<{ + group: GroupEntity; +}> { + const query = ` + query teams($org: String!, $teamSlug: String!) { + organization(login: $org) { + team(slug:$teamSlug) { + slug + combinedSlug + name + description + avatarUrl + editTeamUrl + parentTeam { slug } + members(first: 100, membership: IMMEDIATE) { + pageInfo { hasNextPage } + nodes { login } + } + } + } + }`; + + const materialisedTeam = async ( + item: GithubTeamResponse, + ctx: TransformerContext, + ): Promise => { + const memberNames: GithubUser[] = []; + + if (!item.members.pageInfo.hasNextPage) { + // We got all the members in one go, run the fast path + for (const user of item.members.nodes) { + memberNames.push(user); + } + } else { + // There were more than a hundred immediate members - run the slow + // path of fetching them explicitly + const { members } = await getTeamMembers(ctx.client, ctx.org, item.slug); + for (const userLogin of members) { + memberNames.push(userLogin); + } + } + + const team: GithubTeam = { + ...item, + members: memberNames, + }; + + return await teamTransformer(team, ctx); + }; + + const response: QueryResponse = await client(query, { + org, + teamSlug, + }); + + if (!response.organization?.team) + throw new Error(`Found no match for group ${teamSlug}`); + + const group = await materialisedTeam(response.organization?.team, { + query, + client, + org, + }); + + if (!group) throw new Error(`Can't transform for group ${teamSlug}`); + + return { group }; +} + export async function getOrganizationRepositories( client: typeof graphql, org: string, @@ -435,3 +598,16 @@ export const createRemoveEntitiesOperation = entity: withLocations(`https://${host}`, org, entity), })), }); + +export const createReplaceEntitiesOperation = + (id: string, host: string) => (org: string, entities: Entity[]) => { + const entitiesToReplace = entities.map(entity => ({ + locationKey: `github-org-provider:${id}`, + entity: withLocations(`https://${host}`, org, entity), + })); + + return { + removed: entitiesToReplace, + added: entitiesToReplace, + }; + }; 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 270f184d7a..c24cd573e0 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.test.ts @@ -628,6 +628,26 @@ describe('GithubOrgEntityProvider', () => { const mockClient = jest.fn(); mockClient + .mockResolvedValueOnce({ + organization: { + team: { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }, { login: 'githubuser' }], + }, + }, + }, + }) .mockResolvedValueOnce({ organization: { membersWithRole: { @@ -640,6 +660,13 @@ describe('GithubOrgEntityProvider', () => { email: 'd', avatarUrl: 'e', }, + { + login: 'githubuser', + name: 'githubuser', + bio: 'githubuser', + email: 'd', + avatarUrl: 'e', + }, ], }, }, @@ -662,7 +689,7 @@ describe('GithubOrgEntityProvider', () => { }, members: { pageInfo: { hasNextPage: false }, - nodes: [{ login: 'a' }], + nodes: [{ login: 'a' }, { login: 'githubuser' }], }, }, ], @@ -680,7 +707,7 @@ describe('GithubOrgEntityProvider', () => { action: 'edited', changes: { name: { - from: 'mygroup', + from: 'mygroup with spaces', }, }, team: { @@ -704,11 +731,10 @@ describe('GithubOrgEntityProvider', () => { expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + added: [ { + locationKey: 'github-org-provider:my-id', entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', metadata: { annotations: { 'backstage.io/managed-by-location': @@ -717,24 +743,50 @@ describe('GithubOrgEntityProvider', () => { 'url:https://github.com/a', 'github.com/user-login': 'a', }, - description: 'c', name: 'a', + description: 'c', }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', spec: { - memberOf: ['team'], profile: { displayName: 'b', email: 'd', picture: 'e', }, + memberOf: ['team'], }, }, - locationKey: 'github-org-provider:my-id', }, { + locationKey: 'github-org-provider:my-id', entity: { + metadata: { + 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', + }, + name: 'githubuser', + description: 'githubuser', + }, apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: ['team'], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { metadata: { annotations: { 'backstage.io/managed-by-location': @@ -746,21 +798,50 @@ describe('GithubOrgEntityProvider', () => { name: 'team', description: 'The one and only team', }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', spec: { - children: [], - parent: 'parent', + type: 'team', profile: { displayName: 'Team', picture: 'http://example.com/team.jpeg', }, - type: 'team', - members: ['a'], + children: [], + parent: 'parent', + members: ['a', 'githubuser'], }, }, - locationKey: 'github-org-provider:my-id', }, ], - type: 'full', + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/mygroup-with-spaces', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/mygroup-with-spaces', + }, + name: 'mygroup-with-spaces', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a', 'githubuser'], + }, + }, + }, + ], + type: 'delta', }); }); @@ -795,6 +876,26 @@ describe('GithubOrgEntityProvider', () => { const mockClient = jest.fn(); mockClient + .mockResolvedValueOnce({ + organization: { + team: { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }, { login: 'githubuser' }], + }, + }, + }, + }) .mockResolvedValueOnce({ organization: { membersWithRole: { @@ -807,6 +908,13 @@ describe('GithubOrgEntityProvider', () => { email: 'd', avatarUrl: 'e', }, + { + login: 'githubuser', + name: 'githubuser', + bio: 'githubuser', + email: 'd', + avatarUrl: 'e', + }, ], }, }, @@ -829,7 +937,7 @@ describe('GithubOrgEntityProvider', () => { }, members: { pageInfo: { hasNextPage: false }, - nodes: [{ login: 'a' }], + nodes: [{ login: 'a' }, { login: 'githubuser' }], }, }, ], @@ -867,37 +975,36 @@ describe('GithubOrgEntityProvider', () => { expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + added: [ { + locationKey: 'github-org-provider:my-id', entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', metadata: { annotations: { 'backstage.io/managed-by-location': - 'url:https://github.com/a', + 'url:https://github.com/githubuser', 'backstage.io/managed-by-origin-location': - 'url:https://github.com/a', - 'github.com/user-login': 'a', + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', }, - description: 'c', - name: 'a', + name: 'githubuser', + description: 'githubuser', }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', spec: { - memberOf: ['team'], profile: { - displayName: 'b', + displayName: 'githubuser', email: 'd', picture: 'e', }, + memberOf: ['team'], }, }, - locationKey: 'github-org-provider:my-id', }, { + locationKey: 'github-org-provider:my-id', entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', metadata: { annotations: { 'backstage.io/managed-by-location': @@ -909,21 +1016,78 @@ describe('GithubOrgEntityProvider', () => { name: 'team', description: 'The one and only team', }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', spec: { - children: [], - parent: 'parent', + type: 'team', profile: { displayName: 'Team', picture: 'http://example.com/team.jpeg', }, - type: 'team', - members: ['a'], + children: [], + parent: 'parent', + members: ['a', 'githubuser'], }, }, - locationKey: 'github-org-provider:my-id', }, ], - type: 'full', + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + 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', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: ['team'], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a', 'githubuser'], + }, + }, + }, + ], + type: 'delta', }); }); @@ -958,15 +1122,35 @@ describe('GithubOrgEntityProvider', () => { const mockClient = jest.fn(); mockClient + .mockResolvedValueOnce({ + organization: { + team: { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }], + }, + }, + }, + }) .mockResolvedValueOnce({ organization: { membersWithRole: { pageInfo: { hasNextPage: false }, nodes: [ { - login: 'a', - name: 'b', - bio: 'c', + login: 'githubuser', + name: 'githubuser', + bio: 'githubuser', email: 'd', avatarUrl: 'e', }, @@ -978,24 +1162,7 @@ describe('GithubOrgEntityProvider', () => { organization: { teams: { pageInfo: { hasNextPage: false }, - nodes: [ - { - slug: 'team', - combinedSlug: 'blah/team', - name: 'Team', - description: 'The one and only team', - avatarUrl: 'http://example.com/team.jpeg', - parentTeam: { - slug: 'parent', - combinedSlug: '', - members: { pageInfo: { hasNextPage: false }, nodes: [] }, - }, - members: { - pageInfo: { hasNextPage: false }, - nodes: [{ login: 'a' }], - }, - }, - ], + nodes: [], }, }, }); @@ -1030,37 +1197,36 @@ describe('GithubOrgEntityProvider', () => { expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ - entities: [ + added: [ { + locationKey: 'github-org-provider:my-id', entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', metadata: { annotations: { 'backstage.io/managed-by-location': - 'url:https://github.com/a', + 'url:https://github.com/githubuser', 'backstage.io/managed-by-origin-location': - 'url:https://github.com/a', - 'github.com/user-login': 'a', + 'url:https://github.com/githubuser', + 'github.com/user-login': 'githubuser', }, - description: 'c', - name: 'a', + name: 'githubuser', + description: 'githubuser', }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', spec: { - memberOf: ['team'], profile: { - displayName: 'b', + displayName: 'githubuser', email: 'd', picture: 'e', }, + memberOf: [], }, }, - locationKey: 'github-org-provider:my-id', }, { + locationKey: 'github-org-provider:my-id', entity: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', metadata: { annotations: { 'backstage.io/managed-by-location': @@ -1072,21 +1238,78 @@ describe('GithubOrgEntityProvider', () => { name: 'team', description: 'The one and only team', }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', spec: { - children: [], - parent: 'parent', + type: 'team', profile: { displayName: 'Team', picture: 'http://example.com/team.jpeg', }, - type: 'team', + children: [], + parent: 'parent', members: ['a'], }, }, - locationKey: 'github-org-provider:my-id', }, ], - type: 'full', + removed: [ + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + 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', + }, + name: 'githubuser', + description: 'githubuser', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + spec: { + profile: { + displayName: 'githubuser', + email: 'd', + picture: 'e', + }, + memberOf: [], + }, + }, + }, + { + locationKey: 'github-org-provider:my-id', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + spec: { + type: 'team', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + children: [], + parent: 'parent', + members: ['a'], + }, + }, + }, + ], + type: 'delta', }); }); }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts index 9b11f81697..46ad089625 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubOrgEntityProvider.ts @@ -40,6 +40,8 @@ import { OrganizationMemberAddedEvent, OrganizationMemberRemovedEvent, TeamEvent, + TeamEditedEvent, + MembershipEvent, } from '@octokit/webhooks-types'; import { merge } from 'lodash'; import * as uuid from 'uuid'; @@ -58,7 +60,10 @@ import { TeamTransformer, UserTransformer } from '../lib'; import { createAddEntitiesOperation, createRemoveEntitiesOperation, + createReplaceEntitiesOperation, DeferredEntitiesBuilder, + getOrganizationTeam, + getOrganizationTeamsFromUsers, } from '../lib/github'; /** @@ -255,6 +260,11 @@ export class GithubOrgEntityProvider this.options.gitHubConfig.host, ); + const replaceEntitiesOperation = createReplaceEntitiesOperation( + this.options.id, + this.options.gitHubConfig.host, + ); + // 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')) { @@ -283,14 +293,21 @@ export class GithubOrgEntityProvider : removeEntitiesOperation; await this.onTeamChangeInOrganization(teamEvent, createDeltaOperation); } else if (teamEvent.action === 'edited') { - this.onEventToRebuildOrg(); + await this.onTeamEditedInOrganization( + teamEvent, + replaceEntitiesOperation, + ); } } // 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')) { - this.onEventToRebuildOrg(); + const membershipEvent = params.eventPayload as MembershipEvent; + this.onMembershipChangedInOrganization( + membershipEvent, + replaceEntitiesOperation, + ); } return; @@ -301,6 +318,143 @@ export class GithubOrgEntityProvider return ['github.organization', 'github.team', 'github.membership']; } + private async onTeamEditedInOrganization( + event: TeamEditedEvent, + createDeltaOperation: DeferredEntitiesBuilder, + ) { + if (!this.connection) { + throw new Error('Not initialized'); + } + + const teamSlug = event.team.slug; + const { headers, type: tokenType } = + await this.credentialsProvider.getCredentials({ + url: this.options.orgUrl, + }); + const client = graphql.defaults({ + baseUrl: this.options.gitHubConfig.apiBaseUrl, + headers, + }); + + const { org } = parseGithubOrgUrl(this.options.orgUrl); + const { group } = await getOrganizationTeam( + client, + org, + teamSlug, + this.options.teamTransformer, + ); + + const { users } = await getOrganizationUsers( + client, + org, + tokenType, + this.options.userTransformer, + ); + + const usersFromChangedGroup = group.spec.members || []; + const usersToRebuild = users.filter(u => + usersFromChangedGroup.includes(u.metadata.name), + ); + + const { groups } = await getOrganizationTeamsFromUsers( + client, + org, + usersToRebuild.map(u => u.metadata.name), + this.options.teamTransformer, + ); + + assignGroupsToUsers(usersToRebuild, groups); + buildOrgHierarchy(groups); + + const oldName = event.changes.name?.from || ''; + const oldSlug = oldName.toLowerCase().replaceAll(/\s/gi, '-'); + + const { removed } = createDeltaOperation(org, [ + { + ...group, + metadata: { + name: oldSlug, + }, + }, + ]); + const { added } = createDeltaOperation(org, [...usersToRebuild, ...groups]); + await this.connection.applyMutation({ + type: 'delta', + removed, + added, + }); + } + + private async onMembershipChangedInOrganization( + event: MembershipEvent, + createDeltaOperation: DeferredEntitiesBuilder, + ) { + 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 userLogin = event.member.login; + const { headers, type: tokenType } = + await this.credentialsProvider.getCredentials({ + url: this.options.orgUrl, + }); + const client = graphql.defaults({ + baseUrl: this.options.gitHubConfig.apiBaseUrl, + headers, + }); + + const { org } = parseGithubOrgUrl(this.options.orgUrl); + const { group } = await getOrganizationTeam( + client, + org, + teamSlug, + this.options.teamTransformer, + ); + + const { users } = await getOrganizationUsers( + client, + org, + tokenType, + this.options.userTransformer, + ); + + const usersToRebuild = users.filter(u => u.metadata.name === userLogin); + + const { groups } = await getOrganizationTeamsFromUsers( + client, + org, + [userLogin], + this.options.teamTransformer, + ); + + // we include group because the removed event need to update the old group too + if (!groups.some(g => g.metadata.name === group.metadata.name)) { + groups.push(group); + } + + assignGroupsToUsers(usersToRebuild, groups); + buildOrgHierarchy(groups); + + const { added, removed } = createDeltaOperation(org, [ + ...usersToRebuild, + ...groups, + ]); + await this.connection.applyMutation({ + type: 'delta', + removed, + added, + }); + } + private async onTeamChangeInOrganization( event: TeamEvent, createDeltaOperation: DeferredEntitiesBuilder, @@ -311,13 +465,7 @@ export class GithubOrgEntityProvider const organizationTeamTransformer = this.options.teamTransformer || defaultOrganizationTeamTransformer; - const { - id: databaseId, - name, - html_url: url, - description, - slug, - } = event.team; + const { name, html_url: url, description, slug } = event.team; const org = event.organization.login; const { headers } = await this.credentialsProvider.getCredentials({ url: this.options.orgUrl, @@ -329,7 +477,6 @@ export class GithubOrgEntityProvider const group = (await organizationTeamTransformer( { - databaseId, name, slug, editTeamUrl: `${url}/edit`, @@ -355,16 +502,6 @@ export class GithubOrgEntityProvider }); } - private onEventToRebuildOrg() { - try { - this.read(this.options).then(() => { - this.options.logger.debug('Event process finished'); - }); - } catch (e) { - this.options.logger.debug(`Event process error ${e}`); - } - } - private async onMemberChangeInOrganization( event: OrganizationMemberAddedEvent | OrganizationMemberRemovedEvent, createDeltaOperation: DeferredEntitiesBuilder,