diff --git a/packages/nx/release/changelog-renderer/index.ts b/packages/nx/release/changelog-renderer/index.ts index 4d63ca0cc0a..404dc8a7c7b 100644 --- a/packages/nx/release/changelog-renderer/index.ts +++ b/packages/nx/release/changelog-renderer/index.ts @@ -36,7 +36,8 @@ export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions { authors?: boolean; /** * If authors is enabled, controls whether or not to try to map the authors to their GitHub usernames - * using https://ungh.cc (from https://github.com/unjs/ungh) and the email addresses found in the commits. + * using https://ungh.cc (from https://github.com/unjs/ungh) and, if needed, the GitHub search API via + * the gh CLI and the email addresses found in the commits. * Defaults to true. */ applyUsernameToAuthors?: boolean; diff --git a/packages/nx/src/command-line/release/utils/remote-release-clients/github.spec.ts b/packages/nx/src/command-line/release/utils/remote-release-clients/github.spec.ts new file mode 100644 index 00000000000..587e7534caa --- /dev/null +++ b/packages/nx/src/command-line/release/utils/remote-release-clients/github.spec.ts @@ -0,0 +1,118 @@ +import { GithubRemoteReleaseClient } from './github'; + +jest.mock('axios', () => ({ + get: jest.fn(), +})); + +jest.mock('node:child_process', () => ({ + ...jest.requireActual('node:child_process'), + execFileSync: jest.fn(), + execSync: jest.requireActual('node:child_process').execSync, +})); + +const axiosGetMock = jest.requireMock('axios').get as jest.Mock; +const execFileSyncMock = jest.requireMock('node:child_process') + .execFileSync as jest.Mock; + +describe('GithubRemoteReleaseClient', () => { + const client = new GithubRemoteReleaseClient( + { + hostname: 'github.com', + slug: 'nrwl/nx', + apiBaseUrl: 'https://api.github.com', + }, + false, + null + ); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should prefer the username returned by ungh', async () => { + axiosGetMock.mockResolvedValue({ + data: { + user: { + username: 'from-ungh', + }, + }, + }); + const authors = new Map; username?: string }>([ + ['Test User', { email: new Set(['test@example.com']) }], + ]); + + await client.applyUsernameToAuthors(authors); + + expect(authors.get('Test User')?.username).toBe('from-ungh'); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it('should fall back to gh api when ungh does not return a username', async () => { + axiosGetMock.mockResolvedValue({ + data: { + user: null, + }, + }); + execFileSyncMock.mockReturnValue( + JSON.stringify({ + items: [{ login: 'from-gh' }], + }) + ); + const authors = new Map; username?: string }>([ + ['Test User', { email: new Set(['test@example.com']) }], + ]); + + await client.applyUsernameToAuthors(authors); + + expect(authors.get('Test User')?.username).toBe('from-gh'); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'gh', + [ + 'api', + '--hostname', + 'github.com', + '--method', + 'GET', + 'search/users', + '-f', + 'q=test@example.com in:email', + ], + expect.objectContaining({ + encoding: 'utf8', + stdio: 'pipe', + windowsHide: true, + }) + ); + }); + + it('should fall back to gh api when ungh fails', async () => { + axiosGetMock.mockRejectedValue(new Error('ungh unavailable')); + execFileSyncMock.mockReturnValue( + JSON.stringify({ + items: [{ login: 'from-gh' }], + }) + ); + const authors = new Map; username?: string }>([ + ['Test User', { email: new Set(['test@example.com']) }], + ]); + + await client.applyUsernameToAuthors(authors); + + expect(authors.get('Test User')?.username).toBe('from-gh'); + }); + + it('should leave the username unset when both lookups fail', async () => { + axiosGetMock.mockRejectedValue(new Error('ungh unavailable')); + execFileSyncMock.mockImplementation(() => { + throw new Error('gh unavailable'); + }); + const authors = new Map; username?: string }>([ + ['Test User', { email: new Set(['test@example.com']) }], + ]); + + await expect( + client.applyUsernameToAuthors(authors) + ).resolves.toBeUndefined(); + expect(authors.get('Test User')?.username).toBeUndefined(); + }); +}); diff --git a/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts b/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts index 8c3a721b7b0..a9839d8d443 100644 --- a/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts +++ b/packages/nx/src/command-line/release/utils/remote-release-clients/github.ts @@ -1,6 +1,6 @@ import * as pc from 'picocolors'; import { prompt } from 'enquirer'; -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import { existsSync, promises as fsp } from 'node:fs'; import { homedir } from 'node:os'; import { orange, output } from '../../../../utils/output'; @@ -34,6 +34,18 @@ export interface GithubRemoteRelease { make_latest?: 'legacy' | boolean; } +interface UnghUserLookupResponse { + user?: { + username?: string; + } | null; +} + +interface GithubUserSearchResponse { + items?: Array<{ + login?: string; + }>; +} + export const defaultCreateReleaseProvider: ResolvedCreateRemoteReleaseProvider = { provider: 'github', @@ -169,18 +181,62 @@ export class GithubRemoteReleaseClient extends RemoteReleaseClient(`https://ungh.cc/users/find/${email}`) .catch(() => ({ data: { user: null } })); - if (data?.user) { + if (data?.user?.username) { meta.username = data.user.username; break; } + const usernameFromGhCli = this.resolveUsernameFromGhCli(email); + if (usernameFromGhCli) { + meta.username = usernameFromGhCli; + break; + } } }) ); } + private resolveUsernameFromGhCli(email: string): string | null { + const hostname = + this.getRemoteRepoData()?.hostname ?? + defaultCreateReleaseProvider.hostname; + + try { + const stdout = execFileSync( + 'gh', + [ + 'api', + '--hostname', + hostname, + '--method', + 'GET', + 'search/users', + '-f', + `q=${email} in:email`, + ], + { + encoding: 'utf8', + stdio: 'pipe', + windowsHide: true, + } + ).trim(); + + if (!stdout) { + return null; + } + + const data = JSON.parse(stdout) as GithubUserSearchResponse; + return data.items?.[0]?.login ?? null; + } catch (error) { + if (process.env.NX_VERBOSE_LOGGING === 'true') { + console.error(error); + } + return null; + } + } + /** * Get a release by tag */