diff --git a/.changeset/github-org-verified-emails-config.md b/.changeset/github-org-verified-emails-config.md new file mode 100644 index 0000000000..3e826a7ce6 --- /dev/null +++ b/.changeset/github-org-verified-emails-config.md @@ -0,0 +1,20 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +'@backstage/plugin-catalog-backend-module-github-org': patch +--- + +Added a `defaultUserTransformer.useVerifiedEmails` config option for the `githubOrg` provider. When set to `true`, the default user transformer prefers organization verified domain emails over the user's public GitHub email. Defaults to `false`, which uses only the public GitHub email. + +This option has no effect when a custom user transformer is set via the `githubOrgEntityProviderTransformsExtensionPoint`. + +```yaml +catalog: + providers: + githubOrg: + production: + githubUrl: https://github.com + orgs: + - my-org + defaultUserTransformer: + useVerifiedEmails: true +``` diff --git a/plugins/catalog-backend-module-github-org/src/module.ts b/plugins/catalog-backend-module-github-org/src/module.ts index 137bd84ef7..08a557e3b1 100644 --- a/plugins/catalog-backend-module-github-org/src/module.ts +++ b/plugins/catalog-backend-module-github-org/src/module.ts @@ -23,6 +23,7 @@ import { } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { + buildDefaultUserTransformer, GithubMultiOrgEntityProvider, TeamTransformer, UserTransformer, @@ -116,7 +117,11 @@ export const catalogModuleGithubOrgEntityProvider = createBackendModule({ definition.schedule, ), logger, - userTransformer, + userTransformer: + userTransformer ?? + buildDefaultUserTransformer({ + useVerifiedEmails: definition.useVerifiedEmails, + }), teamTransformer, alwaysUseDefaultNamespace: definitions.length === 1 && definition.orgs?.length === 1, @@ -141,6 +146,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{ organizationMembers?: number; }; excludeSuspendedUsers?: boolean; + useVerifiedEmails?: boolean; }> { const baseKey = 'catalog.providers.githubOrg'; const baseConfig = rootConfig.getOptional(baseKey); @@ -170,5 +176,7 @@ function readDefinitionsFromConfig(rootConfig: Config): Array<{ : undefined, excludeSuspendedUsers: c.getOptionalBoolean('excludeSuspendedUsers') ?? false, + useVerifiedEmails: + c.getOptionalBoolean('defaultUserTransformer.useVerifiedEmails') ?? false, })); } diff --git a/plugins/catalog-backend-module-github/config.d.ts b/plugins/catalog-backend-module-github/config.d.ts index 5425cae9d0..6a9d5cf808 100644 --- a/plugins/catalog-backend-module-github/config.d.ts +++ b/plugins/catalog-backend-module-github/config.d.ts @@ -270,6 +270,24 @@ export interface Config { */ excludeSuspendedUsers?: boolean; + /** + * (Optional) Configuration for the default user transformer. + * These options only apply when using the built-in transformer; + * they have no effect if a custom transformer is set via the + * extension point. + */ + defaultUserTransformer?: { + /** + * (Optional) Whether to prefer organization verified domain emails + * over the user's public GitHub email when populating user entity profiles. + * When enabled, the transformer uses the first verified domain email + * (with plus-addressed routing tags stripped) and falls back to the + * public email if none are available. + * Default: `false`. + */ + useVerifiedEmails?: boolean; + }; + /** * The refresh schedule to use. */ @@ -327,6 +345,24 @@ export interface Config { */ excludeSuspendedUsers?: boolean; + /** + * (Optional) Configuration for the default user transformer. + * These options only apply when using the built-in transformer; + * they have no effect if a custom transformer is set via the + * extension point. + */ + defaultUserTransformer?: { + /** + * (Optional) Whether to prefer organization verified domain emails + * over the user's public GitHub email when populating user entity profiles. + * When enabled, the transformer uses the first verified domain email + * (with plus-addressed routing tags stripped) and falls back to the + * public email if none are available. + * Default: `false`. + */ + useVerifiedEmails?: boolean; + }; + /** * The refresh schedule to use. */ diff --git a/plugins/catalog-backend-module-github/report.api.md b/plugins/catalog-backend-module-github/report.api.md index 25fc58be79..a2642bb1f4 100644 --- a/plugins/catalog-backend-module-github/report.api.md +++ b/plugins/catalog-backend-module-github/report.api.md @@ -25,16 +25,22 @@ import { SchedulerService } from '@backstage/backend-plugin-api'; import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmLocationAnalyzer } from '@backstage/plugin-catalog-node'; -import { UserEntity } from '@backstage/catalog-model'; + +// @public +export function buildDefaultUserTransformer( + options?: DefaultUserTransformerOptions, +): UserTransformer; // @public export const defaultOrganizationTeamTransformer: TeamTransformer; // @public -export const defaultUserTransformer: ( - item: GithubUser, - _ctx: TransformerContext, -) => Promise; +export const defaultUserTransformer: UserTransformer; + +// @public +export interface DefaultUserTransformerOptions { + useVerifiedEmails?: boolean; +} // @public const githubCatalogModule: BackendFeature; diff --git a/plugins/catalog-backend-module-github/src/index.ts b/plugins/catalog-backend-module-github/src/index.ts index 6d62a0a64a..2d02d237dc 100644 --- a/plugins/catalog-backend-module-github/src/index.ts +++ b/plugins/catalog-backend-module-github/src/index.ts @@ -37,6 +37,8 @@ export { type GithubUser, type UserTransformer, defaultUserTransformer, + buildDefaultUserTransformer, + type DefaultUserTransformerOptions, type TeamTransformer, defaultOrganizationTeamTransformer, type TransformerContext, diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts index f90d67bcc9..950906e35a 100644 --- a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.test.ts @@ -17,6 +17,7 @@ import { UserEntity } from '@backstage/catalog-model'; import { graphql } from '@octokit/graphql'; import { + buildDefaultUserTransformer, defaultUserTransformer, TransformerContext, } from './defaultTransformers'; @@ -63,7 +64,7 @@ describe('defaultUserTransformer', () => { expect(result.spec.memberOf).toEqual([]); }); - it('prefers verified domain email over regular email', async () => { + it('uses public email and ignores verified domain emails', async () => { const result = (await defaultUserTransformer( makeUser({ email: 'public@gmail.com', @@ -72,21 +73,10 @@ describe('defaultUserTransformer', () => { ctx, )) as UserEntity; - expect(result.spec.profile!.email).toBe('corp@company.com'); + expect(result.spec.profile!.email).toBe('public@gmail.com'); }); - it('strips plus-addressed tag from verified domain email', async () => { - const result = (await defaultUserTransformer( - makeUser({ - organizationVerifiedDomainEmails: ['amckay+2jc29kv2@spotify.com'], - }), - ctx, - )) as UserEntity; - - expect(result.spec.profile!.email).toBe('amckay@spotify.com'); - }); - - it('uses verified domain email when regular email is absent', async () => { + it('sets no email when public email is absent even if verified emails exist', async () => { const result = (await defaultUserTransformer( makeUser({ organizationVerifiedDomainEmails: ['corp@company.com'], @@ -94,30 +84,7 @@ describe('defaultUserTransformer', () => { ctx, )) as UserEntity; - expect(result.spec.profile!.email).toBe('corp@company.com'); - }); - - it('falls back to regular email when verified array is empty', async () => { - const result = (await defaultUserTransformer( - makeUser({ - email: 'public@gmail.com', - organizationVerifiedDomainEmails: [], - }), - ctx, - )) as UserEntity; - - expect(result.spec.profile!.email).toBe('public@gmail.com'); - }); - - it('falls back to regular email when verified array is undefined', async () => { - const result = (await defaultUserTransformer( - makeUser({ - email: 'public@gmail.com', - }), - ctx, - )) as UserEntity; - - expect(result.spec.profile!.email).toBe('public@gmail.com'); + expect(result.spec.profile!.email).toBeUndefined(); }); it('sets no email when both are absent', async () => { @@ -129,3 +96,119 @@ describe('defaultUserTransformer', () => { expect(result.spec.profile!.email).toBeUndefined(); }); }); + +describe('buildDefaultUserTransformer', () => { + describe('with useVerifiedEmails: false', () => { + const transformer = buildDefaultUserTransformer({ + useVerifiedEmails: false, + }); + + it('uses public email and ignores verified domain emails', async () => { + const result = (await transformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('sets no email when public email is absent', async () => { + const result = (await transformer( + makeUser({ + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBeUndefined(); + }); + }); + + describe('with useVerifiedEmails: true', () => { + const transformer = buildDefaultUserTransformer({ + useVerifiedEmails: true, + }); + + it('prefers verified domain email over public email', async () => { + const result = (await transformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('corp@company.com'); + }); + + it('strips plus-addressed tag from verified domain email', async () => { + const result = (await transformer( + makeUser({ + organizationVerifiedDomainEmails: ['amckay+2jc29kv2@spotify.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('amckay@spotify.com'); + }); + + it('uses verified domain email when public email is absent', async () => { + const result = (await transformer( + makeUser({ + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('corp@company.com'); + }); + + it('falls back to public email when verified array is empty', async () => { + const result = (await transformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: [], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('falls back to public email when verified array is undefined', async () => { + const result = (await transformer( + makeUser({ + email: 'public@gmail.com', + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + + it('sets no email when both are absent', async () => { + const result = (await transformer(makeUser(), ctx)) as UserEntity; + + expect(result.spec.profile!.email).toBeUndefined(); + }); + }); + + describe('with no options', () => { + const transformer = buildDefaultUserTransformer(); + + it('behaves the same as useVerifiedEmails: false', async () => { + const result = (await transformer( + makeUser({ + email: 'public@gmail.com', + organizationVerifiedDomainEmails: ['corp@company.com'], + }), + ctx, + )) as UserEntity; + + expect(result.spec.profile!.email).toBe('public@gmail.com'); + }); + }); +}); diff --git a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts index d71cda270d..34904a59dc 100644 --- a/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-github/src/lib/defaultTransformers.ts @@ -54,42 +54,74 @@ export type TeamTransformer = ( ctx: TransformerContext, ) => Promise; +/** + * Options for {@link buildDefaultUserTransformer}. + * + * @public + */ +export interface DefaultUserTransformerOptions { + /** + * Whether to prefer organization verified domain emails over the user's + * public GitHub email. When enabled, the transformer uses the first + * verified domain email (with plus-addressed routing tags stripped) and + * falls back to the public email if none are available. + * + * @defaultValue false + */ + useVerifiedEmails?: boolean; +} + +/** + * Builds a user transformer with configurable email behavior. + * + * @public + */ +export function buildDefaultUserTransformer( + options?: DefaultUserTransformerOptions, +): UserTransformer { + return async (item, _ctx) => { + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: item.login, + annotations: { + [ANNOTATION_GITHUB_USER_LOGIN]: item.login, + ...(item.id && { [ANNOTATION_GITHUB_USER_ID]: item.id }), + }, + }, + spec: { + profile: {}, + memberOf: [], + }, + }; + + if (item.bio) entity.metadata.description = item.bio; + if (item.name) entity.spec.profile!.displayName = item.name; + + if (options?.useVerifiedEmails) { + // GitHub returns verified domain emails as plus-addressed routing aliases + // (e.g. user+abc123@example.com). Strip the tag to get the real address. + const email = item.organizationVerifiedDomainEmails?.length + ? item.organizationVerifiedDomainEmails[0].replace(/\+[^@]*/, '') + : item.email; + if (email) entity.spec.profile!.email = email; + } else { + if (item.email) entity.spec.profile!.email = item.email; + } + + if (item.avatarUrl) entity.spec.profile!.picture = item.avatarUrl; + return entity; + }; +} + /** * Default transformer for GitHub users to UserEntity * * @public */ -export const defaultUserTransformer = async ( - item: GithubUser, - _ctx: TransformerContext, -): Promise => { - const entity: UserEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: item.login, - annotations: { - [ANNOTATION_GITHUB_USER_LOGIN]: item.login, - ...(item.id && { [ANNOTATION_GITHUB_USER_ID]: item.id }), - }, - }, - spec: { - profile: {}, - memberOf: [], - }, - }; - - if (item.bio) entity.metadata.description = item.bio; - if (item.name) entity.spec.profile!.displayName = item.name; - // GitHub returns verified domain emails as plus-addressed routing aliases - // (e.g. user+abc123@example.com). Strip the tag to get the real address. - const email = item.organizationVerifiedDomainEmails?.length - ? item.organizationVerifiedDomainEmails[0].replace(/\+[^@]*/, '') - : item.email; - if (email) entity.spec.profile!.email = email; - if (item.avatarUrl) entity.spec.profile!.picture = item.avatarUrl; - return entity; -}; +export const defaultUserTransformer: UserTransformer = + buildDefaultUserTransformer(); /** * Default transformer for GitHub Team to GroupEntity diff --git a/plugins/catalog-backend-module-github/src/lib/index.ts b/plugins/catalog-backend-module-github/src/lib/index.ts index 48050e96bb..8806604888 100644 --- a/plugins/catalog-backend-module-github/src/lib/index.ts +++ b/plugins/catalog-backend-module-github/src/lib/index.ts @@ -28,6 +28,8 @@ export { export { type UserTransformer, defaultUserTransformer, + buildDefaultUserTransformer, + type DefaultUserTransformerOptions, type TeamTransformer, defaultOrganizationTeamTransformer, type TransformerContext,