-
Notifications
You must be signed in to change notification settings - Fork 286
Improve "TEAMS" Dashboard Tab: Optimize Metrics Retrieval Based on GitHub Enterprise Type #272
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
c0ad00b
f61b4ac
45be598
3197fd7
e5a6a7a
02e5fe3
720e7df
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 |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import type { QueryObject } from 'ufo'; | |
| import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'; | ||
|
|
||
| export type Scope = 'organization' | 'enterprise' | 'team-organization' | 'team-enterprise'; | ||
| export type EnterpriseType = 'full' | 'copilot-only'; | ||
|
|
||
| export interface OptionsData { | ||
| since?: string; | ||
|
|
@@ -15,6 +16,7 @@ export interface OptionsData { | |
| githubEnt?: string; | ||
| githubTeam?: string; | ||
| scope?: Scope; | ||
| enterpriseType?: EnterpriseType; | ||
| excludeHolidays?: boolean; | ||
| locale?: string; | ||
| } | ||
|
|
@@ -25,6 +27,7 @@ export interface RuntimeConfig { | |
| githubOrg?: string; | ||
| githubEnt?: string; | ||
| githubTeam?: string; | ||
| enterpriseType?: string; | ||
| isDataMocked?: boolean; | ||
| }; | ||
| } | ||
|
|
@@ -54,6 +57,7 @@ export class Options { | |
| public githubEnt?: string; | ||
| public githubTeam?: string; | ||
| public scope?: Scope; | ||
| public enterpriseType?: EnterpriseType; | ||
| public excludeHolidays?: boolean; | ||
| public locale?: string; | ||
|
|
||
|
|
@@ -65,6 +69,7 @@ export class Options { | |
| this.githubEnt = data.githubEnt; | ||
| this.githubTeam = data.githubTeam; | ||
| this.scope = data.scope; | ||
| this.enterpriseType = data.enterpriseType; | ||
| this.excludeHolidays = data.excludeHolidays; | ||
| this.locale = data.locale; | ||
| } | ||
|
|
@@ -109,6 +114,7 @@ export class Options { | |
| if (config.public.githubOrg) options.githubOrg = config.public.githubOrg; | ||
| if (config.public.githubEnt) options.githubEnt = config.public.githubEnt; | ||
| if (config.public.githubTeam) options.githubTeam = config.public.githubTeam; | ||
| if (config.public.enterpriseType) options.enterpriseType = config.public.enterpriseType as EnterpriseType; | ||
| } | ||
|
|
||
| return options; | ||
|
|
@@ -126,6 +132,7 @@ export class Options { | |
| githubEnt: params.get('githubEnt') || undefined, | ||
| githubTeam: params.get('githubTeam') || undefined, | ||
| scope: (params.get('scope') as Scope) || undefined, | ||
| enterpriseType: (params.get('enterpriseType') as EnterpriseType) || undefined, | ||
| locale: params.get('locale') || undefined | ||
| }); | ||
|
|
||
|
|
@@ -149,6 +156,7 @@ export class Options { | |
| githubEnt: query.githubEnt as string | undefined, | ||
| githubTeam: query.githubTeam as string | undefined, | ||
| scope: (query.scope as Scope) || undefined, | ||
| enterpriseType: (query.enterpriseType as EnterpriseType) || undefined, | ||
| locale: query.locale as string | undefined | ||
| }); | ||
|
|
||
|
|
@@ -184,6 +192,7 @@ export class Options { | |
| if (this.githubEnt) params.set('githubEnt', this.githubEnt); | ||
| if (this.githubTeam) params.set('githubTeam', this.githubTeam); | ||
| if (this.scope) params.set('scope', this.scope); | ||
| if (this.enterpriseType) params.set('enterpriseType', this.enterpriseType); | ||
| if (this.excludeHolidays) params.set('excludeHolidays', 'true'); | ||
| if (this.locale) params.set('locale', this.locale); | ||
|
|
||
|
|
@@ -199,6 +208,7 @@ export class Options { | |
| if (this.githubEnt) params.githubEnt = this.githubEnt; | ||
| if (this.githubTeam) params.githubTeam = this.githubTeam; | ||
| if (this.scope) params.scope = this.scope; | ||
| if (this.enterpriseType) params.enterpriseType = this.enterpriseType; | ||
| if (this.excludeHolidays) params.excludeHolidays = String(this.excludeHolidays); | ||
| if (this.locale) params.locale = this.locale; | ||
| return params; | ||
|
|
@@ -217,6 +227,7 @@ export class Options { | |
| if (this.githubEnt !== undefined) result.githubEnt = this.githubEnt; | ||
| if (this.githubTeam !== undefined) result.githubTeam = this.githubTeam; | ||
| if (this.scope !== undefined) result.scope = this.scope; | ||
| if (this.enterpriseType !== undefined) result.enterpriseType = this.enterpriseType; | ||
| if (this.excludeHolidays !== undefined) result.excludeHolidays = this.excludeHolidays; | ||
| if (this.locale !== undefined) result.locale = this.locale; | ||
|
|
||
|
|
@@ -242,6 +253,7 @@ export class Options { | |
| githubEnt: other.githubEnt ?? this.githubEnt, | ||
| githubTeam: other.githubTeam ?? this.githubTeam, | ||
| scope: other.scope ?? this.scope, | ||
| enterpriseType: other.enterpriseType ?? this.enterpriseType, | ||
| excludeHolidays: other.excludeHolidays ?? this.excludeHolidays, | ||
| locale: other.locale ?? this.locale | ||
| }); | ||
|
|
@@ -287,7 +299,23 @@ export class Options { | |
| if (!this.githubEnt || !this.githubTeam) { | ||
| throw new Error('GitHub enterprise and team must be set for team-enterprise scope'); | ||
| } | ||
| url = `${baseUrl}/enterprises/${this.githubEnt}/team/${this.githubTeam}/copilot/metrics`; | ||
| // For full enterprises, teams are organization-based, so we need to use org API | ||
| // For copilot-only enterprises, we use the enterprise team API | ||
| if (this.enterpriseType === 'full') { | ||
| // We need to determine which organization the team belongs to | ||
| // This will be handled by extracting org name from team slug format "org-name - team-name" | ||
| const teamParts = this.githubTeam.split(' - '); | ||
| if (teamParts.length >= 2) { | ||
| const orgName = teamParts[0]; | ||
| const teamName = teamParts[1]; | ||
| url = `${baseUrl}/orgs/${orgName}/team/${teamName}/copilot/metrics`; | ||
| } else { | ||
| throw new Error('Team slug must be in format "org-name - team-name" for full enterprise scope'); | ||
|
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. @liguori are you sure team slug is going to be in this format? 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. No i think here it was Copilot Agent mistake, I didn't have chance to review it. This code must be tested with all the combinations. |
||
| } | ||
| } else { | ||
| // Default to copilot-only behavior | ||
| url = `${baseUrl}/enterprises/${this.githubEnt}/team/${this.githubTeam}/copilot/metrics`; | ||
| } | ||
| break; | ||
|
|
||
| case 'enterprise': | ||
|
|
@@ -355,7 +383,15 @@ export class Options { | |
| if (!this.githubEnt) { | ||
| throw new Error('GitHub enterprise must be set for enterprise scope'); | ||
| } | ||
| return `${baseUrl}/enterprises/${this.githubEnt}/teams`; | ||
| // For full enterprises, teams are fetched via GraphQL + organization teams APIs | ||
| // For copilot-only enterprises, we use the enterprise teams API | ||
| if (this.enterpriseType === 'full') { | ||
| // GraphQL will be used to get organizations, then org teams APIs | ||
| return `${baseUrl}/graphql`; | ||
| } else { | ||
| // Default to copilot-only behavior (enterprise teams API) | ||
| return `${baseUrl}/enterprises/${this.githubEnt}/teams`; | ||
| } | ||
|
|
||
| default: | ||
| throw new Error(`Invalid scope: ${this.scope}`); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,6 +94,7 @@ export default defineNuxtConfig({ | |
| githubOrg: '', | ||
| githubEnt: '', | ||
| githubTeam: '', | ||
| enterpriseType: 'copilot-only', // can be overridden by NUXT_PUBLIC_ENTERPRISE_TYPE environment variable | ||
|
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 think default should be 'full' 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 agree too, but i left like it in order to have the same behavior as the current implementation. Anyway, this new flag could be documented for deployment. |
||
| usingGithubAuth: false, | ||
| version, | ||
| isPublicApp: false | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,8 +1,18 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Options, type Scope } from '@/model/Options' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Options, type Scope, type EnterpriseType } from '@/model/Options' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { H3Event, EventHandlerRequest } from 'h3' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface Team { name: string; slug: string; description: string } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface GitHubTeam { name: string; slug: string; description?: string } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface GraphQLOrganization { login: string; name: string; url: string } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface GraphQLResponse { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enterprise: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| organizations: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nodes: GraphQLOrganization[] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class TeamsError extends Error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| statusCode: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -53,6 +63,7 @@ export async function getTeams(event: H3Event<EventHandlerRequest>): Promise<Tea | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!options.scope && config.public.scope) options.scope = config.public.scope as Scope | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!options.githubOrg && config.public.githubOrg) options.githubOrg = config.public.githubOrg | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!options.githubEnt && config.public.githubEnt) options.githubEnt = config.public.githubEnt | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!options.enterpriseType && config.public.enterpriseType) options.enterpriseType = config.public.enterpriseType as EnterpriseType | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (options.isDataMocked) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info('Using mocked data for teams') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -75,27 +86,75 @@ export async function getTeams(event: H3Event<EventHandlerRequest>): Promise<Tea | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const baseUrl = options.getTeamsApiUrl() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const allTeams: Team[] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let nextUrl: string | null = `${baseUrl}?per_page=100` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let page = 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| while (nextUrl) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info(`Fetching teams page ${page} from ${nextUrl}`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const res = await $fetch.raw(nextUrl, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: event.context.headers | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Handle enterprise scope with different enterprise types | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ((options.scope === 'enterprise' || options.scope === 'team-enterprise') && options.enterpriseType === 'full') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // For full enterprises, we need to use GraphQL to enumerate organizations and get teams from each | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info(`Fetching organizations for full enterprise ${options.githubEnt} using GraphQL`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const graphqlQuery = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| query: `query { enterprise(slug: "${options.githubEnt}") { organizations(first: 100) { nodes { login name url } } } }` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| query: `query { enterprise(slug: "${options.githubEnt}") { organizations(first: 100) { nodes { login name url } } } }` | |
| query: `query($slug: String!) { enterprise(slug: $slug) { organizations(first: 100) { nodes { login name url } } } }`, | |
| variables: { slug: options.githubEnt } |
Copilot
AI
Sep 20, 2025
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.
The hardcoded limit of 100 organizations could be problematic for large enterprises. Consider implementing pagination or making this configurable to handle enterprises with more than 100 organizations.
| const graphqlQuery = { | |
| query: `query { enterprise(slug: "${options.githubEnt}") { organizations(first: 100) { nodes { login name url } } } }` | |
| } | |
| const graphqlResponse = await $fetch<GraphQLResponse>('https://api.github.com/graphql', { | |
| method: 'POST', | |
| headers: event.context.headers, | |
| body: JSON.stringify(graphqlQuery) | |
| }) | |
| const data = res._data as GitHubTeam[] | |
| for (const t of data) { | |
| const name: string = t.name | |
| const slug: string = t.slug | |
| const description: string = t.description || '' | |
| if (name && slug) allTeams.push({ name, slug, description }) | |
| const organizations = graphqlResponse.data.enterprise.organizations.nodes | |
| logger.info(`Found ${organizations.length} organizations in enterprise`) | |
| // Paginate through all organizations in the enterprise | |
| let organizations: GraphQLOrganization[] = []; | |
| let hasNextPage = true; | |
| let endCursor: string | null = null; | |
| while (hasNextPage) { | |
| const graphqlQuery = { | |
| query: `query { | |
| enterprise(slug: "${options.githubEnt}") { | |
| organizations(first: 100${endCursor ? `, after: "${endCursor}"` : ''}) { | |
| nodes { login name url } | |
| pageInfo { hasNextPage endCursor } | |
| } | |
| } | |
| }` | |
| }; | |
| const graphqlResponse = await $fetch<any>('https://api.github.com/graphql', { | |
| method: 'POST', | |
| headers: event.context.headers, | |
| body: JSON.stringify(graphqlQuery) | |
| }); | |
| const orgs = graphqlResponse.data.enterprise.organizations.nodes; | |
| organizations = organizations.concat(orgs); | |
| const pageInfo = graphqlResponse.data.enterprise.organizations.pageInfo; | |
| hasNextPage = pageInfo.hasNextPage; | |
| endCursor = pageInfo.endCursor; | |
| } | |
| logger.info(`Found ${organizations.length} organizations in enterprise`); |
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.
The team slug parsing logic uses
teamParts.length >= 2but only extracts the first two parts. If a team name contains ' - ', this could lead to incorrect parsing. Consider using a more robust parsing approach likesplit(' - ', 2)or joining remaining parts for the team name.