Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/nx/release/changelog-renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { email: Set<string>; 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<string, { email: Set<string>; 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<string, { email: Set<string>; 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<string, { email: Set<string>; username?: string }>([
['Test User', { email: new Set(['test@example.com']) }],
]);

await expect(
client.applyUsernameToAuthors(authors)
).resolves.toBeUndefined();
expect(authors.get('Test User')?.username).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -169,18 +181,62 @@ export class GithubRemoteReleaseClient extends RemoteReleaseClient<GithubRemoteR
const { data } = await axios
.get<
any,
{ data?: { user?: { username: string } } }
{ data?: UnghUserLookupResponse }
>(`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<GithubRepoData>()?.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
*/
Expand Down
Loading