diff --git a/.env b/.env index ac0c1a85..fdbf2a3d 100644 --- a/.env +++ b/.env @@ -10,6 +10,11 @@ NUXT_PUBLIC_GITHUB_ORG=octodemo NUXT_PUBLIC_GITHUB_ENT= +# Determines the GitHub Enterprise type for enterprise-scoped deployments. +# Can be 'full' (GitHub Enterprise with full features) or 'copilot-only' (Copilot Business Only). +# This affects how teams are retrieved and team metrics are accessed. +NUXT_PUBLIC_ENTERPRISE_TYPE=copilot-only + # Determines the team name if exists to target API calls. NUXT_PUBLIC_GITHUB_TEAM= diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 670ab3a7..8fdbf583 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -132,6 +132,39 @@ docker run -it --rm -p 3000:80 \ ghcr.io/github-copilot-resources/copilot-metrics-viewer ``` +### Enterprise Type Configuration + +For enterprise deployments, you can specify the enterprise type to control how teams are retrieved and managed: + +**For Full GitHub Enterprises** (with repos, actions, copilot, etc): +```bash +docker run -it --rm -p 3000:80 \ +-e NUXT_PUBLIC_SCOPE=enterprise \ +-e NUXT_PUBLIC_GITHUB_ENT= \ +-e NUXT_PUBLIC_ENTERPRISE_TYPE=full \ +-e NUXT_GITHUB_TOKEN= \ +-e NUXT_SESSION_PASSWORD= \ +ghcr.io/github-copilot-resources/copilot-metrics-viewer +``` + +**For Copilot Business Only Enterprises** (default behavior): +```bash +docker run -it --rm -p 3000:80 \ +-e NUXT_PUBLIC_SCOPE=enterprise \ +-e NUXT_PUBLIC_GITHUB_ENT= \ +-e NUXT_PUBLIC_ENTERPRISE_TYPE=copilot-only \ +-e NUXT_GITHUB_TOKEN= \ +-e NUXT_SESSION_PASSWORD= \ +ghcr.io/github-copilot-resources/copilot-metrics-viewer +``` + +**Environment Variable Details:** +- `NUXT_PUBLIC_ENTERPRISE_TYPE`: Set to `full` for Full GitHub Enterprises or `copilot-only` for Copilot Business Only enterprises +- Default value: `copilot-only` (maintains backward compatibility) +- This affects how teams are retrieved in the TEAMS tab: + - `full`: Enumerates organizations within the enterprise and fetches teams from each organization + - `copilot-only`: Uses enterprise-level teams API (existing behavior) + ## Health Check Endpoints for Kubernetes The application provides dedicated health check endpoints for Kubernetes deployments that avoid triggering GitHub API calls: diff --git a/README.md b/README.md index 2e2044eb..19687dec 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,13 @@ Users can now filter metrics for custom date ranges up to 100 days, with an intu

### Teams Comparison -Compare Copilot metrics across multiple teams within your organization to understand adoption patterns and identify high-performing teams. +Compare Copilot metrics across multiple teams within your organization or enterprise to understand adoption patterns and identify high-performing teams. + +**Enterprise Support**: The application now supports both Full GitHub Enterprises and Copilot Business Only enterprises: +- **Full Enterprises**: Teams are retrieved from all organizations within the enterprise +- **Copilot Business Only**: Teams are retrieved using enterprise-level APIs + +Configure enterprise type using the `NUXT_PUBLIC_ENTERPRISE_TYPE` environment variable (`full` or `copilot-only`).

Teams Comparison diff --git a/app/model/Options.ts b/app/model/Options.ts index 9d64face..0bf5abab 100644 --- a/app/model/Options.ts +++ b/app/model/Options.ts @@ -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'); + } + } 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}`); diff --git a/nuxt.config.ts b/nuxt.config.ts index ec5dff8f..60a4f834 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -94,6 +94,7 @@ export default defineNuxtConfig({ githubOrg: '', githubEnt: '', githubTeam: '', + enterpriseType: 'copilot-only', // can be overridden by NUXT_PUBLIC_ENTERPRISE_TYPE environment variable usingGithubAuth: false, version, isPublicApp: false diff --git a/package-lock.json b/package-lock.json index bef656e1..6c33ef92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "nuxt-auth-utils": "^0.5.7", "roboto-fontface": "^0.10.0", "undici": ">=7.5.0", - "vue": "*", + "vue": "latest", "vue-chartjs": "^5.3.2", "vuetify": "^3.7.3", "webfontloader": "^1.6.28" diff --git a/server/api/teams.ts b/server/api/teams.ts index 31591c43..3911ad5a 100644 --- a/server/api/teams.ts +++ b/server/api/teams.ts @@ -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): Promise): Promise('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`) + + // For each organization, fetch its teams + for (const org of organizations) { + const orgTeamsUrl = `https://api.github.com/orgs/${org.login}/teams` + let nextTeamsUrl: string | null = `${orgTeamsUrl}?per_page=100` + let teamsPage = 1 + + while (nextTeamsUrl) { + logger.info(`Fetching teams page ${teamsPage} from ${nextTeamsUrl} for org ${org.login}`) + const teamsRes = await $fetch.raw(nextTeamsUrl, { + headers: event.context.headers + }) + + const teamsData = teamsRes._data as GitHubTeam[] + for (const t of teamsData) { + const name: string = `${org.login} - ${t.name}` + const slug: string = `${org.login} - ${t.slug}` + const description: string = t.description || `Team ${t.name} from organization ${org.login}` + if (t.name && t.slug) allTeams.push({ name, slug, description }) + } + + const teamsLinkHeader = teamsRes.headers.get('link') || teamsRes.headers.get('Link') + const teamsLinks = parseLinkHeader(teamsLinkHeader) + nextTeamsUrl = teamsLinks['next'] || null + teamsPage += 1 + } } + } else { + // Handle organization scope or copilot-only enterprise scope (original logic) + 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 + }) - const linkHeader = res.headers.get('link') || res.headers.get('Link') - const links = parseLinkHeader(linkHeader) - nextUrl = links['next'] || null - page += 1 + 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 linkHeader = res.headers.get('link') || res.headers.get('Link') + const links = parseLinkHeader(linkHeader) + nextUrl = links['next'] || null + page += 1 + } } return allTeams diff --git a/tests/enterprise-type.spec.ts b/tests/enterprise-type.spec.ts new file mode 100644 index 00000000..bc058d7a --- /dev/null +++ b/tests/enterprise-type.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import { Options } from '@/model/Options' + +describe('Enterprise Type Support', () => { + describe('Options class enterprise type handling', () => { + it('should handle copilot-only enterprise type', () => { + const options = new Options({ + scope: 'enterprise', + githubEnt: 'test-enterprise', + enterpriseType: 'copilot-only' + }) + + expect(options.enterpriseType).toBe('copilot-only') + expect(options.getTeamsApiUrl()).toBe('https://api.github.com/enterprises/test-enterprise/teams') + }) + + it('should handle full enterprise type', () => { + const options = new Options({ + scope: 'enterprise', + githubEnt: 'test-enterprise', + enterpriseType: 'full' + }) + + expect(options.enterpriseType).toBe('full') + expect(options.getTeamsApiUrl()).toBe('https://api.github.com/graphql') + }) + + it('should default to copilot-only behavior when enterprise type not specified', () => { + const options = new Options({ + scope: 'enterprise', + githubEnt: 'test-enterprise' + }) + + expect(options.getTeamsApiUrl()).toBe('https://api.github.com/enterprises/test-enterprise/teams') + }) + + it('should handle team-enterprise scope with copilot-only type', () => { + const options = new Options({ + scope: 'team-enterprise', + githubEnt: 'test-enterprise', + githubTeam: 'test-team', + enterpriseType: 'copilot-only' + }) + + expect(options.getApiUrl()).toBe('https://api.github.com/enterprises/test-enterprise/team/test-team/copilot/metrics') + }) + + it('should handle team-enterprise scope with full type', () => { + const options = new Options({ + scope: 'team-enterprise', + githubEnt: 'test-enterprise', + githubTeam: 'org-name - team-name', + enterpriseType: 'full' + }) + + expect(options.getApiUrl()).toBe('https://api.github.com/orgs/org-name/team/team-name/copilot/metrics') + }) + + it('should throw error for invalid team slug format in full enterprise', () => { + const options = new Options({ + scope: 'team-enterprise', + githubEnt: 'test-enterprise', + githubTeam: 'invalid-team-slug', + enterpriseType: 'full' + }) + + expect(() => options.getApiUrl()).toThrow('Team slug must be in format "org-name - team-name" for full enterprise scope') + }) + + it('should include enterprise type in serialization methods', () => { + const options = new Options({ + scope: 'enterprise', + githubEnt: 'test-enterprise', + enterpriseType: 'full' + }) + + const params = options.toParams() + expect(params.enterpriseType).toBe('full') + + const urlParams = options.toURLSearchParams() + expect(urlParams.get('enterpriseType')).toBe('full') + + const obj = options.toObject() + expect(obj.enterpriseType).toBe('full') + }) + + it('should handle enterprise type in fromQuery method', () => { + const options = Options.fromQuery({ + scope: 'enterprise', + githubEnt: 'test-enterprise', + enterpriseType: 'full' + }) + + expect(options.enterpriseType).toBe('full') + }) + + it('should handle enterprise type in fromURLSearchParams method', () => { + const params = new URLSearchParams() + params.set('scope', 'enterprise') + params.set('githubEnt', 'test-enterprise') + params.set('enterpriseType', 'full') + + const options = Options.fromURLSearchParams(params) + expect(options.enterpriseType).toBe('full') + }) + + it('should handle enterprise type in merge method', () => { + const options1 = new Options({ + scope: 'enterprise', + githubEnt: 'test-enterprise', + enterpriseType: 'copilot-only' + }) + + const options2 = new Options({ + enterpriseType: 'full' + }) + + const merged = options1.merge(options2) + expect(merged.enterpriseType).toBe('full') + }) + }) +}) \ No newline at end of file