Repository navigation
Optimize peer-review team membership lookups #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
||
| 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>> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a candidate's login has another fuzzy match that GitHub returns first, this Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Here are the conclusions I draw:
|
||
| 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; | ||
|
|
||

There was a problem hiding this comment.
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:
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.