feat(events,catalog/github): handle github team.edited and membership events
Signed-off-by: Rogerio Angeliski <angeliski@hotmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+479
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user