diff --git a/README.md b/README.md index 2e2044eb..f7c68b3c 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,12 @@ can be overridden by route parameters, e.g. #### NUXT_PUBLIC_SCOPE (Required!) -The `NUXT_PUBLIC_SCOPE` environment variable in the `.env` file determines the default scope of the API calls made by the application. It can be set to 'enterprise', 'organization', 'team-organization' or 'team-enterprise'. +The `NUXT_PUBLIC_SCOPE` environment variable in the `.env` file determines the default scope of the API calls made by the application. It can be set to 'enterprise', 'organization', 'team-organization', 'team-enterprise', or 'multi-organization'. - If set to 'enterprise', the application will target API calls to the GitHub Enterprise account defined in the `NUXT_PUBLIC_GITHUB_ENT` variable. - If set to 'organization', the application will target API calls to the GitHub Organization account defined in the `NUXT_PUBLIC_GITHUB_ORG` variable. -- If set to 'team', the application will target API calls to GitHub Team defined in the `NUXT_PUBLIC_GITHUB_TEAM` variable under `NUXT_PUBLIC_GITHUB_ORG` GitHub Organization. +- If set to 'team-organization' or 'team-enterprise', the application will target API calls to GitHub Team defined in the `NUXT_PUBLIC_GITHUB_TEAM` variable under the respective organization or enterprise. +- If set to 'multi-organization', the application will aggregate metrics from multiple organizations defined in `NUXT_PUBLIC_GITHUB_ORG` as a comma-separated list. For example, if you want to target the API calls to an organization, you would set `NUXT_PUBLIC_SCOPE=organization` in the `.env` file. @@ -187,6 +188,33 @@ NUXT_PUBLIC_GITHUB_ORG= NUXT_PUBLIC_GITHUB_ENT= ```` +#### NUXT_PUBLIC_GITHUB_ORG (Required for Organization/Multi-Organization Scope) + +The `NUXT_PUBLIC_GITHUB_ORG` environment variable specifies the GitHub organization(s) to target: + +- **Single Organization:** Set it to a single organization name +- **Multiple Organizations:** Set it to a comma-separated list of organization names + +When two or more organizations are provided (comma-separated), the application will automatically use the 'multi-organization' scope and aggregate metrics from all specified organizations. + +>[!TIP] +> This is perfect for getting a combined view of Copilot usage across multiple organizations within your company. + +**Example for single organization:** +```` +NUXT_PUBLIC_GITHUB_ORG=my-org +NUXT_PUBLIC_SCOPE=organization +```` + +**Example for multiple organizations:** +```` +NUXT_PUBLIC_GITHUB_ORG=org1,org2,org3 +NUXT_PUBLIC_SCOPE=multi-organization +```` + +>[!NOTE] +> When `NUXT_PUBLIC_GITHUB_ORG` contains 2+ organizations (comma-separated), the scope will automatically be set to 'multi-organization' regardless of the `NUXT_PUBLIC_SCOPE` value. + #### NUXT_PUBLIC_GITHUB_TEAM The `NUXT_PUBLIC_GITHUB_TEAM` environment variable filters metrics for a specific GitHub team within an Enterprise or Organization account. diff --git a/app/components/AgentModeViewer.vue b/app/components/AgentModeViewer.vue index ee02c03f..45deab14 100644 --- a/app/components/AgentModeViewer.vue +++ b/app/components/AgentModeViewer.vue @@ -49,14 +49,14 @@ - Statistics for code completions in integrated development environments. + Average daily users for code completions in integrated development environments.
{{ stats.totalIdeCodeCompletionUsers }}
-
Total Users with Activity
+
Average Daily Users with Activity
{{ stats.totalIdeCodeCompletionModels }} Models Used
@@ -71,14 +71,14 @@ - Statistics for chat interactions in integrated development environments. + Average daily users for chat interactions in integrated development environments.
{{ stats.totalIdeChatUsers }}
-
Total Users with Activity
+
Average Daily Users with Activity
{{ stats.totalIdeChatModels }} Models Used
@@ -92,14 +92,14 @@ - Statistics for chat interactions on GitHub.com web interface. + Average daily users for chat interactions on GitHub.com web interface.
{{ stats.totalDotcomChatUsers }}
-
Total Users with Activity
+
Average Daily Users with Activity
{{ stats.totalDotcomChatModels }} Models Used
@@ -409,14 +409,14 @@ export default defineComponent({ { title: 'Model Name', key: 'name' }, { title: 'Editor', key: 'editor' }, { title: 'Type', key: 'model_type' }, - { title: 'Total Users with Activity', key: 'total_engaged_users' } + { title: 'Avg Daily Users with Activity', key: 'total_engaged_users' } ]; const ideChatHeaders = [ { title: 'Model Name', key: 'name' }, { title: 'Editor', key: 'editor' }, { title: 'Type', key: 'model_type' }, - { title: 'Total Users with Activity', key: 'total_engaged_users' }, + { title: 'Avg Daily Users with Activity', key: 'total_engaged_users' }, { title: 'Total Chats', key: 'total_chats' }, { title: 'Insertions', key: 'total_chat_insertion_events' }, { title: 'Copy Events', key: 'total_chat_copy_events' } @@ -425,7 +425,7 @@ export default defineComponent({ const dotcomChatHeaders = [ { title: 'Model Name', key: 'name' }, { title: 'Type', key: 'model_type' }, - { title: 'Total Users with Activity', key: 'total_engaged_users' }, + { title: 'Avg Daily Users with Activity', key: 'total_engaged_users' }, { title: 'Total Chats', key: 'total_chats' } ]; @@ -433,7 +433,7 @@ export default defineComponent({ { title: 'Model Name', key: 'name' }, { title: 'Repository', key: 'repository' }, { title: 'Type', key: 'model_type' }, - { title: 'Total Users with Activity', key: 'total_engaged_users' }, + { title: 'Avg Daily Users with Activity', key: 'total_engaged_users' }, { title: 'PR Summaries', key: 'total_pr_summaries_created' } ]; diff --git a/app/components/ChampionsViewer.vue b/app/components/ChampionsViewer.vue new file mode 100644 index 00000000..35e54060 --- /dev/null +++ b/app/components/ChampionsViewer.vue @@ -0,0 +1,520 @@ + + + + + diff --git a/app/components/MainComponent.vue b/app/components/MainComponent.vue index 9e35b491..39cb7586 100644 --- a/app/components/MainComponent.vue +++ b/app/components/MainComponent.vue @@ -86,6 +86,9 @@ v-if="item === 'copilot chat'" :metrics="metrics" + @@ -54,9 +54,9 @@ v-if="selectedTeams.length > 0" color="primary" variant="outlined" size="small" - {{ team.name }} - View Details + {{ team.displayName }} - View Details mdi-open-in-new @@ -99,11 +99,11 @@ elevation="4" color="white" variant="elevated" class="mx-auto my-3"
- Combined total active users across all - selected teams + Sum of average daily active users across all + selected teams in the date range
{{ dateRangeDesc }}
@@ -111,6 +111,27 @@ elevation="4" color="white" variant="elevated" class="mx-auto my-3"
+ + + +
+
+ + + + Average acceptance rate by count across all + selected teams in the date range + + +
{{ dateRangeDesc }}
+

{{ avgAcceptanceRate }}%

+
+ +
@@ -320,6 +341,9 @@ interface Team { name: string slug: string description?: string + organization?: string // Track which org this team belongs to + uniqueId?: string // Unique identifier: org/slug or just slug + displayName?: string // Display name with org context if needed } interface LanguageTeamData { team: string; language: string; acceptance_rate: number } @@ -372,23 +396,34 @@ export default defineComponent({ elements: { bar: { borderWidth: 1 } } } - const selectedTeamObjects = computed(() => availableTeams.value.filter(team => selectedTeams.value.includes(team.slug))) + const selectedTeamObjects = computed(() => availableTeams.value.filter(team => selectedTeams.value.includes(team.uniqueId || team.slug))) const scopeType = computed(() => { const config = useRuntimeConfig() + if (config.public.scope === 'multi-organization') return 'organizations' return config.public.scope === 'enterprise' ? 'enterprise' : 'organization' }) // Aggregate total active users across selected teams (latest day for each) const aggregatedTotalActiveUsers = ref(0) const totalActiveUsers = computed(() => aggregatedTotalActiveUsers.value) + + // Average acceptance rate across all selected teams + const aggregatedAvgAcceptanceRate = ref(0) + const avgAcceptanceRate = computed(() => aggregatedAvgAcceptanceRate.value.toFixed(1)) const clearSelection = () => { selectedTeams.value = [] } - const getTeamDetailUrl = (teamSlug: string) => { + const getTeamDetailUrl = (team: Team) => { const config = useRuntimeConfig() - return config.public.scope === 'enterprise' - ? `/enterprises/${config.public.githubEnt}/teams/${teamSlug}` - : `/orgs/${config.public.githubOrg}/teams/${teamSlug}` + const org = team.organization || config.public.githubOrg + const ent = config.public.githubEnt + + if (config.public.scope === 'enterprise' || config.public.scope === 'team-enterprise') { + return `/enterprises/${ent}/teams/${team.slug}` + } + return `/orgs/${org}/teams/${team.slug}` } + const getTeamName = (uniqueId: string) => availableTeams.value.find(t => t.uniqueId === uniqueId)?.displayName || uniqueId + const loadTeams = async () => { const route = useRoute(); @@ -396,16 +431,41 @@ export default defineComponent({ const params = options.toParams(); const teams = await $fetch('/api/teams', { params }) - availableTeams.value = teams + + // Add uniqueId and displayName to each team + const config = useRuntimeConfig() + const isMultiOrg = config.public.scope === 'multi-organization' + + availableTeams.value = teams.map(team => ({ + ...team, + uniqueId: team.organization ? `${team.organization}/${team.slug}` : team.slug, + displayName: team.organization && isMultiOrg ? `${team.name} (${team.organization})` : team.name + })) } // Load metrics for a single team via /api/metrics (old + new formats) - const loadMetricsForTeam = async (teamSlug: string) => { + const loadMetricsForTeam = async (teamUniqueId: string) => { const route = useRoute(); const options = Options.fromRoute(route, props.dateRange.since, props.dateRange.until); + + // Find the team by uniqueId + const teamObj = availableTeams.value.find(t => t.uniqueId === teamUniqueId); + if (!teamObj) return { metrics: [], usage: [] }; + // Force scope to team variant based on current broader scope - if (options.scope === 'enterprise') options.scope = 'team-enterprise'; - else if (options.scope === 'organization') options.scope = 'team-organization'; - options.githubTeam = teamSlug; + if (options.scope === 'enterprise') { + options.scope = 'team-enterprise'; + } else if (options.scope === 'organization' || options.scope === 'multi-organization') { + options.scope = 'team-organization'; + // For multi-organization, use the team's organization if available + if (teamObj.organization) { + options.githubOrg = teamObj.organization; + } else if (options.githubOrgs && options.githubOrgs.length > 0) { + // Fallback to first org if organization not tracked + options.githubOrg = options.githubOrgs[0]; + } + } + + options.githubTeam = teamObj.slug; const params = options.toParams(); const response = await $fetch('/api/metrics', { params }) return response; @@ -414,14 +474,15 @@ export default defineComponent({ const generateBarChartData = () => { // Generate language bar chart data const languages = [...new Set(languageComparison.value.map(l => l.language))] - const teams = [...new Set(languageComparison.value.map(l => l.team))] + const teamUniqueIds = [...new Set(languageComparison.value.map(l => l.team))] - const languageDatasets = teams.map((team, index) => { + const languageDatasets = teamUniqueIds.map((uniqueId, index) => { + const teamName = getTeamName(uniqueId) const colorIndex = index % teamColors.length return { - label: team, + label: teamName, data: languages.map(language => { - const langData = languageComparison.value.find(l => l.language === language && l.team === team) + const langData = languageComparison.value.find(l => l.language === language && l.team === uniqueId) return langData ? langData.acceptance_rate : 0 }), backgroundColor: teamColors[colorIndex]!.border, @@ -438,12 +499,13 @@ export default defineComponent({ // Generate editor bar chart data const editors = [...new Set(editorComparison.value.map(e => e.editor))] - const editorDatasets = teams.map((team, index) => { + const editorDatasets = teamUniqueIds.map((uniqueId, index) => { + const teamName = getTeamName(uniqueId) const colorIndex = index % teamColors.length return { - label: team, + label: teamName, data: editors.map(editor => { - const editorData = editorComparison.value.find(e => e.editor === editor && e.team === team) + const editorData = editorComparison.value.find(e => e.editor === editor && e.team === uniqueId) return editorData ? editorData.active_users : 0 }), backgroundColor: teamColors[colorIndex]!.border, @@ -487,12 +549,12 @@ export default defineComponent({ } // Fetch metrics for each selected team individually - const perTeamResponses = await Promise.all(selectedTeams.value.map(slug => loadMetricsForTeam(slug))) + const perTeamResponses = await Promise.all(selectedTeams.value.map(uniqueId => loadMetricsForTeam(uniqueId))) // Build a structure for quick lookup - interface PerTeamData { slug: string; metrics: Metrics[]; usage: CopilotMetrics[] } + interface PerTeamData { uniqueId: string; metrics: Metrics[]; usage: CopilotMetrics[] } const perTeamData: PerTeamData[] = perTeamResponses.map((resp, idx) => ({ - slug: selectedTeams.value[idx]!, + uniqueId: selectedTeams.value[idx]!, metrics: (resp.metrics as Metrics[]) || [], usage: (resp.usage as CopilotMetrics[]) || [] })) @@ -504,12 +566,10 @@ export default defineComponent({ perTeamData.forEach(t => t.usage.forEach((u) => { if (u.date) daySet.add(u.date) })) const days = Array.from(daySet).sort() - const getTeamName = (slug: string) => availableTeams.value.find(t => t.slug === slug)?.name || slug - // Helper to create line datasets pulling from Metrics objects const createMetricsDatasets = (metricKey: LineMetricKey, label: string): ChartDataset<'line', number[]>[] => { return perTeamData.map((teamData, index) => { - const teamName = getTeamName(teamData.slug) + const teamName = getTeamName(teamData.uniqueId) const colorIndex = index % teamColors.length return { label: `${teamName} - ${label}`, @@ -532,7 +592,7 @@ export default defineComponent({ // Suggestions & Acceptances datasets const suggestionsDatasets: ChartDataset<'line', number[]>[] = [] perTeamData.forEach((teamData, index) => { - const teamName = getTeamName(teamData.slug) + const teamName = getTeamName(teamData.uniqueId) const colorIndex = index % teamColors.length const suggestionsDataset: ChartDataset<'line', number[]> = { label: `${teamName} - Suggestions`, @@ -567,7 +627,7 @@ export default defineComponent({ // Lines suggested & accepted const linesDatasets: ChartDataset<'line', number[]>[] = [] perTeamData.forEach((teamData, index) => { - const teamName = getTeamName(teamData.slug) + const teamName = getTeamName(teamData.uniqueId) const colorIndex = index % teamColors.length linesDatasets.push( { @@ -603,7 +663,7 @@ export default defineComponent({ // Feature usage charts derived from NEW usage format (CopilotMetrics) const createUsageDataset = (path: string[], label: string): ChartDataset<'line', number[]>[] => { return perTeamData.map((teamData, index) => { - const teamName = getTeamName(teamData.slug) + const teamName = getTeamName(teamData.uniqueId) const colorIndex = index % teamColors.length return { label: `${teamName} - ${label}`, @@ -651,25 +711,52 @@ export default defineComponent({ }) Object.entries(langAgg).forEach(([language, vals]) => { const rate = vals.suggestions ? (vals.acceptances / vals.suggestions) * 100 : 0 - langComp.push({ team: teamData.slug, language, acceptance_rate: rate }) + langComp.push({ team: teamData.uniqueId, language, acceptance_rate: rate }) }) Object.entries(editorAgg).forEach(([editor, active]) => { - editorComp.push({ team: teamData.slug, editor, active_users: active }) + editorComp.push({ team: teamData.uniqueId, editor, active_users: active }) }) }) languageComparison.value = langComp editorComparison.value = editorComp generateBarChartData() - // Update total active users (latest day per team) + // Update total active users (average across date range per team, excluding weekends) let totalActive = 0 perTeamData.forEach(teamData => { if (teamData.metrics.length) { - const latest = [...teamData.metrics].sort((a, b) => a.day.localeCompare(b.day)).at(-1) - totalActive += latest?.total_active_users || 0 + const weekdayUsers = teamData.metrics + .filter(d => { + const dayOfWeek = new Date(d.day).getDay() + return dayOfWeek !== 5 && dayOfWeek !== 6 // Exclude Friday (5) and Saturday (6) + }) + .map(d => d.total_active_users || 0) + + if (weekdayUsers.length > 0) { + const avgActiveUsers = weekdayUsers.reduce((sum, users) => sum + users, 0) / weekdayUsers.length + totalActive += avgActiveUsers + } + } + }) + aggregatedTotalActiveUsers.value = Math.round(totalActive) + + // Calculate average acceptance rate across all teams using cumulative totals (same as MetricsViewer) + let totalAcceptanceRate = 0 + let teamCount = 0 + perTeamData.forEach(teamData => { + if (teamData.metrics.length) { + // Calculate using cumulative totals for this team + const totalSuggestions = teamData.metrics.reduce((sum, m) => sum + (m.total_suggestions_count || 0), 0) + const totalAcceptances = teamData.metrics.reduce((sum, m) => sum + (m.total_acceptances_count || 0), 0) + + if (totalSuggestions > 0) { + const teamRate = (totalAcceptances / totalSuggestions) * 100 + totalAcceptanceRate += teamRate + teamCount++ + } } }) - aggregatedTotalActiveUsers.value = totalActive + aggregatedAvgAcceptanceRate.value = teamCount > 0 ? totalAcceptanceRate / teamCount : 0 } // Load teams on mount, then react to selection changes @@ -704,6 +791,7 @@ export default defineComponent({ selectedTeamObjects, scopeType, totalActiveUsers, + avgAcceptanceRate, clearSelection, getTeamDetailUrl, generateBarChartData, diff --git a/app/model/Options.ts b/app/model/Options.ts index 9d64face..f33cbcae 100644 --- a/app/model/Options.ts +++ b/app/model/Options.ts @@ -5,13 +5,14 @@ import type { QueryObject } from 'ufo'; import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router'; -export type Scope = 'organization' | 'enterprise' | 'team-organization' | 'team-enterprise'; +export type Scope = 'organization' | 'enterprise' | 'team-organization' | 'team-enterprise' | 'multi-organization'; export interface OptionsData { since?: string; until?: string; isDataMocked?: boolean; githubOrg?: string; + githubOrgs?: string[]; // Multiple organizations for multi-org scope githubEnt?: string; githubTeam?: string; scope?: Scope; @@ -23,6 +24,7 @@ export interface RuntimeConfig { public: { scope?: string; githubOrg?: string; + githubOrgs?: string; // Comma-separated list of orgs githubEnt?: string; githubTeam?: string; isDataMocked?: boolean; @@ -51,6 +53,7 @@ export class Options { public until?: string; public isDataMocked?: boolean; public githubOrg?: string; + public githubOrgs?: string[]; // Multiple organizations public githubEnt?: string; public githubTeam?: string; public scope?: Scope; @@ -62,6 +65,7 @@ export class Options { this.until = data.until; this.isDataMocked = data.isDataMocked; this.githubOrg = data.githubOrg; + this.githubOrgs = data.githubOrgs; this.githubEnt = data.githubEnt; this.githubTeam = data.githubTeam; this.scope = data.scope; @@ -106,7 +110,18 @@ export class Options { } else { // Use defaults from runtime config options.scope = (config.public.scope as Scope) || 'organization'; - if (config.public.githubOrg) options.githubOrg = config.public.githubOrg; + if (config.public.githubOrg) { + // Check if githubOrg contains comma-separated list + const orgs = config.public.githubOrg.split(',').map(org => org.trim()).filter(Boolean); + if (orgs.length > 1) { + // Multi-organization mode + options.githubOrgs = orgs; + options.scope = 'multi-organization'; + } else { + // Single organization + options.githubOrg = orgs[0]; + } + } if (config.public.githubEnt) options.githubEnt = config.public.githubEnt; if (config.public.githubTeam) options.githubTeam = config.public.githubTeam; } @@ -119,10 +134,12 @@ export class Options { * Create Options from URLSearchParams */ static fromURLSearchParams(params: URLSearchParams): Options { + const githubOrgsParam = params.get('githubOrgs'); const options = new Options({ since: params.get('since') || undefined, until: params.get('until') || undefined, githubOrg: params.get('githubOrg') || undefined, + githubOrgs: githubOrgsParam ? githubOrgsParam.split(',').map(org => org.trim()).filter(Boolean) : undefined, githubEnt: params.get('githubEnt') || undefined, githubTeam: params.get('githubTeam') || undefined, scope: (params.get('scope') as Scope) || undefined, @@ -142,10 +159,16 @@ export class Options { } static fromQuery(query: QueryObject): Options { + const githubOrgsQuery = query.githubOrgs; + const githubOrgsArray = typeof githubOrgsQuery === 'string' + ? githubOrgsQuery.split(',').map(org => org.trim()).filter(Boolean) + : Array.isArray(githubOrgsQuery) ? githubOrgsQuery : undefined; + const options = new Options({ since: query.since as string | undefined, until: query.until as string | undefined, githubOrg: query.githubOrg as string | undefined, + githubOrgs: githubOrgsArray, githubEnt: query.githubEnt as string | undefined, githubTeam: query.githubTeam as string | undefined, scope: (query.scope as Scope) || undefined, @@ -181,6 +204,7 @@ export class Options { if (this.until) params.set('until', this.until); if (this.isDataMocked) params.set('isDataMocked', 'true'); if (this.githubOrg) params.set('githubOrg', this.githubOrg); + if (this.githubOrgs && this.githubOrgs.length > 0) params.set('githubOrgs', this.githubOrgs.join(',')); if (this.githubEnt) params.set('githubEnt', this.githubEnt); if (this.githubTeam) params.set('githubTeam', this.githubTeam); if (this.scope) params.set('scope', this.scope); @@ -196,6 +220,7 @@ export class Options { if (this.until) params.until = this.until; if (this.isDataMocked) params.isDataMocked = String(this.isDataMocked); if (this.githubOrg) params.githubOrg = this.githubOrg; + if (this.githubOrgs && this.githubOrgs.length > 0) params.githubOrgs = this.githubOrgs.join(','); if (this.githubEnt) params.githubEnt = this.githubEnt; if (this.githubTeam) params.githubTeam = this.githubTeam; if (this.scope) params.scope = this.scope; @@ -214,6 +239,7 @@ export class Options { if (this.until !== undefined) result.until = this.until; if (this.isDataMocked !== undefined) result.isDataMocked = this.isDataMocked; if (this.githubOrg !== undefined) result.githubOrg = this.githubOrg; + if (this.githubOrgs !== undefined) result.githubOrgs = this.githubOrgs; if (this.githubEnt !== undefined) result.githubEnt = this.githubEnt; if (this.githubTeam !== undefined) result.githubTeam = this.githubTeam; if (this.scope !== undefined) result.scope = this.scope; @@ -239,6 +265,7 @@ export class Options { until: other.until ?? this.until, isDataMocked: other.isDataMocked ?? this.isDataMocked, githubOrg: other.githubOrg ?? this.githubOrg, + githubOrgs: other.githubOrgs ?? this.githubOrgs, githubEnt: other.githubEnt ?? this.githubEnt, githubTeam: other.githubTeam ?? this.githubTeam, scope: other.scope ?? this.scope, @@ -258,13 +285,14 @@ export class Options { * Check if GitHub organization/enterprise settings are configured */ hasGitHubConfig(): boolean { - return Boolean(this.githubOrg || this.githubEnt); + return Boolean(this.githubOrg || this.githubOrgs?.length || this.githubEnt); } /** - * Get the API URL based on scope and configuration + * Get the API URL(s) based on scope and configuration + * Returns an array for multi-organization scope, single string otherwise */ - getApiUrl(): string { + getApiUrl(): string | string[] { const baseUrl = 'https://api.github.com/'; let url = ''; @@ -297,6 +325,23 @@ export class Options { url = `${baseUrl}/enterprises/${this.githubEnt}/copilot/metrics`; break + case 'multi-organization': + if (!this.githubOrgs || this.githubOrgs.length === 0) { + throw new Error('GitHub organizations must be set for multi-organization scope'); + } + // Return array of URLs for each organization + const urls = this.githubOrgs.map(org => { + let orgUrl = `${baseUrl}/orgs/${org}/copilot/metrics`; + if (this.since || this.until) { + const sinceParam = this.since ? `since=${encodeURIComponent(this.since)}` : ''; + const untilParam = this.until ? `until=${encodeURIComponent(this.until)}` : ''; + const params = [sinceParam, untilParam].filter(Boolean).join('&'); + orgUrl += params ? `?${params}` : ''; + } + return orgUrl; + }); + return urls; + default: throw new Error(`Invalid scope: ${this.scope}`); } @@ -311,9 +356,10 @@ export class Options { } /** - * Get the Seats API URL based on scope and configuration + * Get the Seats API URL(s) based on scope and configuration + * Returns an array for multi-organization scope, single string otherwise */ - getSeatsApiUrl(): string { + getSeatsApiUrl(): string | string[] { const baseUrl = 'https://api.github.com/'; switch (this.scope) { @@ -331,6 +377,12 @@ export class Options { } return `${baseUrl}/enterprises/${this.githubEnt}/copilot/billing/seats`; + case 'multi-organization': + if (!this.githubOrgs || this.githubOrgs.length === 0) { + throw new Error('GitHub organizations must be set for multi-organization scope'); + } + return this.githubOrgs.map(org => `${baseUrl}/orgs/${org}/copilot/billing/seats`); + default: throw new Error(`Invalid scope: ${this.scope}`); } @@ -338,8 +390,9 @@ export class Options { /** * Get the Teams API URL based on scope and configuration + * Returns an array for multi-organization scope, single string otherwise */ - getTeamsApiUrl(): string { + getTeamsApiUrl(): string | string[] { const baseUrl = 'https://api.github.com/'; switch (this.scope) { @@ -357,6 +410,12 @@ export class Options { } return `${baseUrl}/enterprises/${this.githubEnt}/teams`; + case 'multi-organization': + if (!this.githubOrgs || this.githubOrgs.length === 0) { + throw new Error('GitHub organizations must be set for multi-organization scope'); + } + return this.githubOrgs.map(org => `${baseUrl}/orgs/${org}/teams`); + default: throw new Error(`Invalid scope: ${this.scope}`); } @@ -443,6 +502,14 @@ export class Options { } } + if (this.scope === 'multi-organization') { + if (!this.githubOrgs || this.githubOrgs.length === 0) { + errors.push('GitHub organizations must be set for multi-organization scope'); + } else if (this.githubOrgs.length < 2) { + errors.push('At least two organizations must be specified for multi-organization scope'); + } + } + if (this.scope === 'enterprise' || this.scope === 'team-enterprise') { if (!this.githubEnt) { errors.push('GitHub enterprise must be set for enterprise scopes'); diff --git a/nuxt.config.ts b/nuxt.config.ts index ec5dff8f..75c0afdc 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -91,7 +91,7 @@ export default defineNuxtConfig({ public: { isDataMocked: false, // can be overridden by NUXT_PUBLIC_IS_DATA_MOCKED environment variable scope: 'organization', // can be overridden by NUXT_PUBLIC_SCOPE environment variable - githubOrg: '', + githubOrg: '', // can be a single org or comma-separated list for multi-org githubEnt: '', githubTeam: '', usingGithubAuth: false, diff --git a/server/api/github-stats.ts b/server/api/github-stats.ts index 9d3ffc12..010ce78f 100644 --- a/server/api/github-stats.ts +++ b/server/api/github-stats.ts @@ -58,6 +58,13 @@ function calculateGitHubStats(metrics: CopilotMetrics[]): GitHubStats { totalPRSummariesCreated: 0 }); + // Calculate averages for user counts (divide by number of days with data) + const daysCount = metrics.length || 1; + totals.totalIdeCodeCompletionUsers = Math.round(totals.totalIdeCodeCompletionUsers / daysCount); + totals.totalIdeChatUsers = Math.round(totals.totalIdeChatUsers / daysCount); + totals.totalDotcomChatUsers = Math.round(totals.totalDotcomChatUsers / daysCount); + totals.totalDotcomPRUsers = Math.round(totals.totalDotcomPRUsers / daysCount); + // Calculate unique models with optimized approach const modelSets = { ideCodeCompletion: new Set(), @@ -157,6 +164,23 @@ function calculateGitHubStats(metrics: CopilotMetrics[]): GitHubStats { }); } + // Convert summed user counts to averages for model details + modelMaps.ideCodeCompletion.forEach((value) => { + value.total_engaged_users = Math.round(value.total_engaged_users / daysCount); + }); + + modelMaps.ideChat.forEach((value) => { + value.total_engaged_users = Math.round(value.total_engaged_users / daysCount); + }); + + modelMaps.dotcomChat.forEach((value) => { + value.total_engaged_users = Math.round(value.total_engaged_users / daysCount); + }); + + modelMaps.dotcomPR.forEach((value) => { + value.total_engaged_users = Math.round(value.total_engaged_users / daysCount); + }); + // Chart data const labels = metrics.map(metric => metric.date); const agentModeChartData = { diff --git a/server/api/seats.ts b/server/api/seats.ts index 9d850737..f0fc4a22 100644 --- a/server/api/seats.ts +++ b/server/api/seats.ts @@ -114,6 +114,62 @@ export default defineEventHandler(async (event) => { // if scope is team - get team members const teamMembers: TeamMember[] = await fetchAllTeamMembers(options, event.context.headers); + // Handle multi-organization scope + const isMultiOrg = Array.isArray(apiUrl); + let allSeats: Seat[] = []; + + if (isMultiOrg) { + logger.info(`Fetching seats data from ${apiUrl.length} organizations`); + + for (const url of apiUrl) { + try { + let page = 1; + const perPage = 100; + + logger.info(`Fetching seats from ${url}`); + let response = await $fetch(url, { + headers: event.context.headers, + params: { + per_page: perPage, + page: page + } + }) as { seats: unknown[], total_seats: number }; + + let orgSeats = response.seats.map((item: unknown) => new Seat(item)); + const totalSeats = response.total_seats; + const totalPages = Math.ceil(totalSeats / perPage); + + // Fetch remaining pages for this org + for (page = 2; page <= totalPages; page++) { + response = await $fetch(url, { + headers: event.context.headers, + params: { + per_page: perPage, + page: page + } + }) as { seats: unknown[], total_seats: number }; + + orgSeats = orgSeats.concat(response.seats.map((item: unknown) => new Seat(item))); + } + + allSeats = allSeats.concat(orgSeats); + } catch (error: unknown) { + logger.error(`Error fetching seats for ${url}:`, error); + // Continue with other orgs even if one fails + } + } + + // Deduplicate across all organizations + const deduplicatedSeats = deduplicateSeats(allSeats); + + if (teamMembers.length > 0) { + return deduplicatedSeats.filter(seat => teamMembers.some(member => member.id === seat.id)); + } + + return deduplicatedSeats; + } + + // Single organization logic const perPage = 100; let page = 1; let response; diff --git a/server/api/teams.ts b/server/api/teams.ts index 31591c43..0bceff24 100644 --- a/server/api/teams.ts +++ b/server/api/teams.ts @@ -1,7 +1,12 @@ import { Options, type Scope } from '@/model/Options' import type { H3Event, EventHandlerRequest } from 'h3' -interface Team { name: string; slug: string; description: string } +interface Team { + name: string; + slug: string; + description: string; + organization?: string; // Track which org this team belongs to for multi-org scenarios +} interface GitHubTeam { name: string; slug: string; description?: string } class TeamsError extends Error { @@ -74,6 +79,54 @@ export async function getTeams(event: H3Event): Promise org.trim()).filter(Boolean); + const orgsList = orgsArray.join(', '); + return `Copilot Metrics Viewer | Organizations : ${orgsList}`; + } + const topLevelScope = input.githubEnt ? 'Enterprise' : 'Organization'; return `Copilot Metrics Viewer | ${topLevelScope} : ${input.githubOrg || input.githubEnt} ${teamName}`; diff --git a/shared/utils/metrics-util.ts b/shared/utils/metrics-util.ts index edda02b9..978e0f5f 100644 --- a/shared/utils/metrics-util.ts +++ b/shared/utils/metrics-util.ts @@ -104,8 +104,49 @@ export async function getMetricsData(event: H3Event): Promi throw new MetricsError('No Authentication provided', 401); } - // Build auth-bound cache key - const path = event.path || '/api/metrics'; // fallback path (should always exist in practice) + // Handle multi-organization scope + const isMultiOrg = Array.isArray(apiUrl); + if (isMultiOrg) { + logger.info(`Fetching metrics data from ${apiUrl.length} organizations`); + const allMetrics: CopilotMetrics[] = []; + + for (const url of apiUrl) { + const path = event.path || '/api/metrics'; + const cacheKey = buildMetricsCacheKey(path, query as QueryParams, authHeader); + + // Check cache for this org + const cachedData = cache.get(cacheKey + ':' + url); + if (cachedData && cachedData.valid_until > Date.now() / 1000) { + logger.info(`Returning cached data for ${url}`); + allMetrics.push(...cachedData.data); + continue; + } + + try { + const response = await $fetch(url, { + headers: event.context.headers + }) as unknown[]; + + const usageData = ensureCopilotMetrics(response as CopilotMetrics[]); + const filteredUsageData = filterHolidaysFromMetrics(usageData, options.excludeHolidays || false, options.locale); + + // Cache each org's data separately + const validUntil = Math.floor(Date.now() / 1000) + 5 * 60; + cache.set(cacheKey + ':' + url, { data: filteredUsageData, valid_until: validUntil }); + + allMetrics.push(...filteredUsageData); + } catch (error: unknown) { + logger.error(`Error fetching metrics for ${url}:`, error); + // Continue with other orgs even if one fails + } + } + + // Aggregate metrics by date + return aggregateMetricsByDate(allMetrics); + } + + // Build auth-bound cache key for single org + const path = event.path || '/api/metrics'; const cacheKey = buildMetricsCacheKey(path, query as QueryParams, authHeader); // Attempt cache lookup with auth fingerprint validation @@ -203,3 +244,116 @@ function ensureCopilotMetrics(data: CopilotMetrics[]): CopilotMetrics[] { return item as CopilotMetrics; }); }; + +/** + * Aggregate metrics from multiple organizations by date + * Combines all metrics for the same date into a single entry + */ +function aggregateMetricsByDate(metrics: CopilotMetrics[]): CopilotMetrics[] { + const aggregated = new Map(); + + for (const metric of metrics) { + const date = metric.date; + + if (!aggregated.has(date)) { + // First metric for this date - clone it + aggregated.set(date, JSON.parse(JSON.stringify(metric))); + } else { + // Aggregate with existing metric for this date + const existing = aggregated.get(date)!; + + // IMPORTANT: For total_active_users in multi-org scenarios, we take the MAX instead of SUM + // because users may be counted in multiple organizations (would cause double-counting) + // This gives us a conservative estimate of unique active users + existing.total_active_users = Math.max( + existing.total_active_users || 0, + metric.total_active_users || 0 + ); + + // Merge copilot_ide_code_completions + if (metric.copilot_ide_code_completions) { + if (!existing.copilot_ide_code_completions) { + existing.copilot_ide_code_completions = { editors: [], total_engaged_users: 0, languages: [] }; + } + + existing.copilot_ide_code_completions.total_engaged_users = + (existing.copilot_ide_code_completions.total_engaged_users || 0) + + (metric.copilot_ide_code_completions.total_engaged_users || 0); + + // Merge editors, languages, and models + mergeBreakdowns(existing.copilot_ide_code_completions.editors || [], metric.copilot_ide_code_completions.editors || []); + mergeBreakdowns(existing.copilot_ide_code_completions.languages || [], metric.copilot_ide_code_completions.languages || []); + } + + // Merge copilot_ide_chat + if (metric.copilot_ide_chat) { + if (!existing.copilot_ide_chat) { + existing.copilot_ide_chat = { editors: [], total_engaged_users: 0 }; + } + + existing.copilot_ide_chat.total_engaged_users = + (existing.copilot_ide_chat.total_engaged_users || 0) + + (metric.copilot_ide_chat.total_engaged_users || 0); + + mergeBreakdowns(existing.copilot_ide_chat.editors || [], metric.copilot_ide_chat.editors || []); + } + + // Merge copilot_dotcom_chat and copilot_dotcom_pull_requests similarly + if (metric.copilot_dotcom_chat) { + if (!existing.copilot_dotcom_chat) { + existing.copilot_dotcom_chat = { models: [], total_engaged_users: 0 }; + } + + existing.copilot_dotcom_chat.total_engaged_users = + (existing.copilot_dotcom_chat.total_engaged_users || 0) + + (metric.copilot_dotcom_chat.total_engaged_users || 0); + + mergeBreakdowns(existing.copilot_dotcom_chat.models || [], metric.copilot_dotcom_chat.models || []); + } + + if (metric.copilot_dotcom_pull_requests) { + if (!existing.copilot_dotcom_pull_requests) { + existing.copilot_dotcom_pull_requests = { repositories: [], total_engaged_users: 0 }; + } + + existing.copilot_dotcom_pull_requests.total_engaged_users = + (existing.copilot_dotcom_pull_requests.total_engaged_users || 0) + + (metric.copilot_dotcom_pull_requests.total_engaged_users || 0); + + mergeBreakdowns(existing.copilot_dotcom_pull_requests.repositories || [], metric.copilot_dotcom_pull_requests.repositories || []); + } + } + } + + return Array.from(aggregated.values()).sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Helper function to merge breakdown arrays (editors, languages, models, etc.) + * @param target - The target array to merge into + * @param source - The source array to merge from + */ +function mergeBreakdowns(target: T[], source: T[]): void { + for (const sourceItem of source) { + const targetItem = target.find(item => item.name === sourceItem.name); + + if (!targetItem) { + // New item - add a copy + target.push(JSON.parse(JSON.stringify(sourceItem))); + } else { + // Existing item - sum numeric properties + for (const key in sourceItem) { + if (key === 'name') continue; + + if (typeof sourceItem[key] === 'number') { + targetItem[key] = (targetItem[key] || 0) + sourceItem[key]; + } else if (Array.isArray(sourceItem[key])) { + // Recursively merge nested arrays + if (!targetItem[key]) targetItem[key] = []; + mergeBreakdowns(targetItem[key], sourceItem[key]); + } + } + } + } +} +