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
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
33 changes: 33 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<enterprise name> \
-e NUXT_PUBLIC_ENTERPRISE_TYPE=full \
-e NUXT_GITHUB_TOKEN=<github PAT> \
-e NUXT_SESSION_PASSWORD=<random string - min 32 characters> \
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=<enterprise name> \
-e NUXT_PUBLIC_ENTERPRISE_TYPE=copilot-only \
-e NUXT_GITHUB_TOKEN=<github PAT> \
-e NUXT_SESSION_PASSWORD=<random string - min 32 characters> \
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:
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ Users can now filter metrics for custom date ranges up to 100 days, with an intu
</p>

### 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`).

<p align="center">
<img width="800" alt="Teams Comparison" src="./images/teams-comparison.png">
Expand Down
40 changes: 38 additions & 2 deletions app/model/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +16,7 @@ export interface OptionsData {
githubEnt?: string;
githubTeam?: string;
scope?: Scope;
enterpriseType?: EnterpriseType;
excludeHolidays?: boolean;
locale?: string;
}
Expand All @@ -25,6 +27,7 @@ export interface RuntimeConfig {
githubOrg?: string;
githubEnt?: string;
githubTeam?: string;
enterpriseType?: string;
isDataMocked?: boolean;
};
}
Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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
});

Expand All @@ -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
});

Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -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
});
Expand Down Expand Up @@ -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];
Comment on lines +307 to +310
Copy link

Copilot AI Sep 20, 2025

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 >= 2 but only extracts the first two parts. If a team name contains ' - ', this could lead to incorrect parsing. Consider using a more robust parsing approach like split(' - ', 2) or joining remaining parts for the team name.

Suggested change
const teamParts = this.githubTeam.split(' - ');
if (teamParts.length >= 2) {
const orgName = teamParts[0];
const teamName = teamParts[1];
const separator = ' - ';
const sepIndex = this.githubTeam.indexOf(separator);
if (sepIndex > 0) {
const orgName = this.githubTeam.substring(0, sepIndex);
const teamName = this.githubTeam.substring(sepIndex + separator.length);

Copilot uses AI. Check for mistakes.
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');
Copy link
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Author

Choose a reason for hiding this comment

The 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':
Expand Down Expand Up @@ -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}`);
Expand Down
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default defineNuxtConfig({
githubOrg: '',
githubEnt: '',
githubTeam: '',
enterpriseType: 'copilot-only', // can be overridden by NUXT_PUBLIC_ENTERPRISE_TYPE environment variable
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think default should be 'full'

Copy link
Author

Choose a reason for hiding this comment

The 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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 78 additions & 19 deletions server/api/teams.ts
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
Expand Down Expand Up @@ -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')
Expand All @@ -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 } } } }`
Copy link

Copilot AI Sep 20, 2025

Choose a reason for hiding this comment

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

Direct string interpolation in GraphQL query creates a potential injection vulnerability. The enterprise slug should be properly escaped or parameterized to prevent GraphQL injection attacks.

Suggested change
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 uses AI. Check for mistakes.
}

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`)

Comment on lines +95 to +107
Copy link

Copilot AI Sep 20, 2025

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.

Suggested change
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`);

Copilot uses AI. Check for mistakes.
// 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
Expand Down
Loading
Loading