Skip to content
Open
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
19 changes: 18 additions & 1 deletion scripts/libs/GitHubAPIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ type InternalOctokit = InstanceType<typeof OctokitWithPlugins>;

const OctokitWithPlugins = Octokit.plugin(throttling, paginateRest);

type GraphQLVariable<Name extends string = string> = {
name: Name;
value: string;
declaration: `$${Name}: String!`;
reference: `$${Name}`;
};

function createGraphQLVariable<Name extends string>(name: Name, value: string): GraphQLVariable<Name> {
return {
name,
value,
declaration: `$${name}: String!`,
reference: `$${name}`,
};
}

/**
* This GitHub API client:
* - Exposes utils for octokit (rest), graphql, and pagination.
Expand Down Expand Up @@ -67,4 +83,5 @@ class GitHubAPIClient {
}

export default GitHubAPIClient;
export type {InternalOctokit};
export {createGraphQLVariable};
export type {GraphQLVariable, InternalOctokit};
157 changes: 92 additions & 65 deletions scripts/libs/GitHubUtils/getTeamMemberLogins.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,116 @@
import type GitHubAPIClient from '../GitHubAPIClient';
import {createGraphQLVariable} from '../GitHubAPIClient';
import type {GraphQLVariable} from '../GitHubAPIClient';
Comment on lines +2 to +3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda think this is overkill. Variables aren't actually necessary in GraphQL queries, you can inline the values. Variables are nice if:

  • You're writing documentation
  • The query is defined in an external lib and the values are user-submitted
  • The values require sanitization for GraphQL-sensitive characters (e.g. quotes)

In this case, you've jumped through a lot of hoops to structure these variables only to destructure them and interpolate the declarations. So it would be fewer steps and likely more readable to interpolate the values directly.


const EXPENSIFY_ORG = 'Expensify';

type TeamMembersResponse = {
organization: {
team: {
members: {
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: Array<{
login: string;
}>;
};
} | null;
} | null;
type TeamMemberSearch = {
login: string;
alias: string;
variable: GraphQLVariable;
};

type TeamSearch = {
slug: string;
alias: string;
variable: GraphQLVariable;
};

const teamMemberLoginsPromisesByClient = new WeakMap<GitHubAPIClient, Map<string, Promise<Set<string>>>>();
type TeamMemberSearchResult = {
nodes: Array<{login: string}>;
};

type TeamMembersResponse = {
organization: Record<string, Record<string, TeamMemberSearchResult | null> | null> | null;
};

/**
* Fetches and memoizes all members of a GitHub team for the lifetime of a client.
* Returns the candidate logins that belong to any of the teams, batching all searches into one request.
*/
async function getTeamMemberLogins(client: GitHubAPIClient, teamSlug: string): Promise<Set<string>> {
let teamPromises = teamMemberLoginsPromisesByClient.get(client);
if (!teamPromises) {
teamPromises = new Map();
teamMemberLoginsPromisesByClient.set(client, teamPromises);
}
async function getTeamMemberLogins(client: GitHubAPIClient, teamSlugs: string[], candidateLogins: string[]): Promise<Set<string>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB can you add some comments throughout this function?

const teamMemberLogins = new Set<string>();
const uniqueTeamSlugs = new Set(teamSlugs);
const uniqueCandidateLogins = new Set(candidateLogins);

let teamMembersPromise = teamPromises.get(teamSlug);
if (!teamMembersPromise) {
teamMembersPromise = fetchTeamMemberLogins(client, teamSlug);
teamPromises.set(teamSlug, teamMembersPromise);
if (uniqueTeamSlugs.size === 0 || uniqueCandidateLogins.size === 0) {
return teamMemberLogins;
}

return teamMembersPromise;
}
const organizationVariable = createGraphQLVariable('organization', EXPENSIFY_ORG);
const memberSearches: TeamMemberSearch[] = [...uniqueCandidateLogins].map((login, index) => {
const alias = `member${index}`;
return {
login,
alias,
variable: createGraphQLVariable(alias, login),
};
});
const teamSearches: TeamSearch[] = [...uniqueTeamSlugs].map((slug, index) => {
const alias = `team${index}`;
return {
slug,
alias,
variable: createGraphQLVariable(`teamSlug${index}`, slug),
};
});
Comment on lines +39 to +55

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of this is necessary IMO. The org can be hardcoded, you don't need variables for the teams or members, and and the aliases can be defined when you loop through them below to insert them into the query.


async function fetchTeamMemberLogins(client: GitHubAPIClient, teamSlug: string): Promise<Set<string>> {
const teamMemberLogins = new Set<string>();
let cursor: string | null = null;
let hasNextPage = true;

while (hasNextPage) {
// await-in-loop is necessary and appropriate for polling a paginated endpoint;
// each request is dependent upon the response of the previous.
// eslint-disable-next-line no-await-in-loop
const response: TeamMembersResponse = await client.graphql<TeamMembersResponse>(
`
query TeamMembers($organization: String!, $teamSlug: String!, $cursor: String) {
organization(login: $organization) {
team(slug: $teamSlug) {
members(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
login
}
}
const queryVariables: GraphQLVariable[] = [organizationVariable, ...memberSearches.map(({variable}) => variable), ...teamSearches.map(({variable}) => variable)];
const teamQueries = teamSearches.map((teamSearch) => {
const memberQueries = memberSearches.map(
(memberSearch) => `
${memberSearch.alias}: members(first: 1, query: ${memberSearch.variable.reference}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve all fuzzy search results before rejecting a member

When a candidate's login has another fuzzy match that GitHub returns first, this first: 1 query never sees the exact login. The code itself notes that members(query:) can be fuzzy, but it no longer requests pageInfo or follows subsequent results, so a genuine team member can be omitted from eligibleReviewers and their valid approval will incorrectly fail the peer-review gate. Fetch enough results and continue paginating unresolved searches until an exact login is found or the connection is exhausted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is a real problem, because GitHub should give exact matches first, before other potential fuzzy matches

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested with the only users I could find in our org where one user's name is a subset of another. Here were the results:

Image

Here are the conclusions I draw:

  • The issue the AI reviewer cited is not a real concern - an exact match should be the first result
  • first: 1 is not strictly necessary. though I wouldn't remove it, since restricting the result set to exactly what you need is kinda the whole point of GraphQL
  • Despite GH usernames being case-sensitive, this search is not
  • Whether or not first: 1 is passed, the presence of a result is not sufficient. Comparing the actual username (which you do) is necessary

nodes {
login
}
}
}
`,
{
organization: EXPENSIFY_ORG,
teamSlug,
cursor,
},
`,
);

const members = response.organization?.team?.members;
if (!members) {
throw new Error(`${EXPENSIFY_ORG}/${teamSlug} team could not be found.`);
return `
${teamSearch.alias}: team(slug: ${teamSearch.variable.reference}) {
${memberQueries.join('\n')}
}
`;
});

const variables: Record<string, string> = {};
const variableDeclarations: Array<GraphQLVariable['declaration']> = [];
for (const variable of queryVariables) {
variables[variable.name] = variable.value;
variableDeclarations.push(variable.declaration);
}

const query = `
query TeamMembers(${variableDeclarations.join(', ')}) {
organization(login: ${organizationVariable.reference}) {
${teamQueries.join('\n')}
}
}
`;

for (const member of members.nodes) {
teamMemberLogins.add(member.login);
const response = await client.graphql<TeamMembersResponse>(query, variables);
const organization = response.organization;
if (!organization) {
throw new Error(`${EXPENSIFY_ORG} organization could not be found.`);
}

for (const teamSearch of teamSearches) {
const team = organization[teamSearch.alias];
if (!team) {
throw new Error(`${EXPENSIFY_ORG}/${teamSearch.slug} team could not be found.`);
}

hasNextPage = members.pageInfo.hasNextPage;
cursor = members.pageInfo.endCursor;
for (const memberSearch of memberSearches) {
const members = team[memberSearch.alias];
if (!members) {
throw new Error(`Missing team membership search result for ${memberSearch.login} in ${teamSearch.slug}.`);
}

// GitHub's query can be fuzzy, so only an exact login match proves membership.
if (members.nodes.some((member) => member.login === memberSearch.login)) {
teamMemberLogins.add(memberSearch.login);
}
}
}

return teamMemberLogins;
Expand Down
2 changes: 1 addition & 1 deletion scripts/libs/GitHubUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function createGitHubUtils(client: GitHubAPIClient) {
getPullRequestCommitCount: (args: {owner: string; repo: string; number: number}) => getPullRequestCommitCount(client, args),
getRequiredApprovingReviewCount: (args: {owner: string; repo: string; baseRef: string}) => getRequiredApprovingReviewCount(client, args),
isBotUser,
getTeamMemberLogins: (teamSlug: string) => getTeamMemberLogins(client, teamSlug),
getTeamMemberLogins: (teamSlugs: string[], candidateLogins: string[]) => getTeamMemberLogins(client, teamSlugs, candidateLogins),
listPullRequestCommits: (args: {owner: string; repo: string; number: number}) => listPullRequestCommits(client, args),
};
}
Expand Down
3 changes: 1 addition & 2 deletions scripts/verifyPeerReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ async function getIndependentApprovers(gitHubUtils: GitHubUtils, approvers: stri
const authorsSet = new Set(authors);
const independentApprovers = approvers.filter((approver) => !authorsSet.has(approver));
const reviewerTeams = [EXPENSIFY_EMPLOYEE_TEAM_SLUG, ...(REPOSITORY_REVIEWER_TEAMS.get(`${owner}/${repo}`) ?? [])];
const teamMembers = await Promise.all(reviewerTeams.map((teamSlug) => gitHubUtils.getTeamMemberLogins(teamSlug)));
const eligibleReviewers = new Set(teamMembers.flatMap((members) => [...members]));
const eligibleReviewers = await gitHubUtils.getTeamMemberLogins(reviewerTeams, independentApprovers);

return independentApprovers.filter((approver) => eligibleReviewers.has(approver));
}
Expand Down
75 changes: 68 additions & 7 deletions tests/GitHubUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,21 +392,82 @@ describe('GitHubUtils', () => {
});

describe('getTeamMemberLogins', () => {
it('returns the fetched team login set', async () => {
it('batches unique candidate searches across teams and rejects fuzzy and differently cased matches', async () => {
let calls = 0;
const gitHubUtils = createGitHubUtils(
createMockClient(async (query, variables) => {
calls++;
assert.match(query, /team0: team\(slug: \$teamSlug0\)/);
assert.match(query, /team1: team\(slug: \$teamSlug1\)/);
assert.match(query, /member0: members\(first: 1, query: \$member0\)/);
assert.match(query, /member1: members\(first: 1, query: \$member1\)/);
assert.doesNotMatch(query, /pageInfo|after:/);
assert.deepEqual(variables, {
organization: 'Expensify',
member0: 'AndrewGable',
member1: 'outsider',
teamSlug0: 'expensify-expensify',
teamSlug1: 'writers',
});
return {
organization: {
team0: {
member0: {nodes: [{login: 'AndrewGable'}]},
member1: {nodes: [{login: 'outsider-other'}]},
},
team1: {
member0: {nodes: []},
member1: {nodes: [{login: 'Outsider'}]},
},
},
};
}),
);
assert.deepEqual(
await gitHubUtils.getTeamMemberLogins(['expensify-expensify', 'writers', 'expensify-expensify'], ['AndrewGable', 'outsider', 'AndrewGable']),
new Set(['AndrewGable']),
);
assert.equal(calls, 1);
});

it('skips the API when there are no teams or candidates', async () => {
const gitHubUtils = createGitHubUtils(
createMockClient(async () => {
throw new Error('Unexpected API call');
}),
);
assert.deepEqual(await gitHubUtils.getTeamMemberLogins(['team'], []), new Set());
assert.deepEqual(await gitHubUtils.getTeamMemberLogins([], ['alice']), new Set());
});

it('fails closed for missing organizations, teams, or search results', async () => {
for (const response of [{organization: null}, {organization: {team0: null}}, {organization: {team0: {}}}, {organization: {team0: {member0: null}}}]) {
const gitHubUtils = createGitHubUtils(createMockClient(async () => response));
await assert.rejects(() => gitHubUtils.getTeamMemberLogins(['team'], ['alice']));
}
});

it('propagates API failures', async () => {
const gitHubUtils = createGitHubUtils(
createMockClient(async () => {
throw new Error('Forbidden');
}),
);
await assert.rejects(() => gitHubUtils.getTeamMemberLogins(['team'], ['alice']), /Forbidden/);
});

it('returns only exact candidate logins', async () => {
const gitHubUtils = createGitHubUtils(
createMockClient(async () => ({
organization: {
team: {
members: {
pageInfo: {hasNextPage: false, endCursor: null},
nodes: [{login: 'AndrewGable'}],
},
team0: {
member0: {nodes: [{login: 'AndrewGable'}]},
},
},
})),
);

const logins = await gitHubUtils.getTeamMemberLogins('expensify-expensify');
const logins = await gitHubUtils.getTeamMemberLogins(['expensify-expensify'], ['AndrewGable']);

// GitHub logins are case-sensitive, so this is a direct set lookup, not a case-insensitive match.
assert.equal(logins.has('AndrewGable'), true);
Expand Down
Loading
Loading