Skip to content

Commit c29cbd4

Browse files
abhi-singhsCopilot
andauthored
feat: enable enterprise team metrics via Enterprise Teams API (#348)
* feat: enable enterprise team metrics via Enterprise Teams API - Update enterprise team members endpoint to /memberships (new API) - Add X-GitHub-Api-Version: 2026-03-10 header for enterprise teams/members - Add team-scoped direct API path in metrics-util-v2 (no DB required) - Make teams tab always visible (remove historical mode restriction) - Add defensive response normalization for enterprise team members - Improve aggregateTeamMetrics to use case-insensitive login matching - Add tests for enterprise team members URL and case-insensitive matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * add rate limit warning banner on teams tab when not using historical mode --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c4fc929 commit c29cbd4

10 files changed

Lines changed: 177 additions & 32 deletions

File tree

app/components/TeamsComponent.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@
1212
</div>
1313
</v-card>
1414

15+
<!-- Rate limit warning when not using historical/DB mode -->
16+
<v-alert
17+
v-if="!isHistoricalMode"
18+
type="warning"
19+
variant="tonal"
20+
density="compact"
21+
class="mx-4 mb-1"
22+
closable
23+
>
24+
<strong>Performance notice:</strong> Team metrics are computed by fetching enterprise-wide user data and filtering by team membership.
25+
Each team selection triggers a full user-metrics download, which may hit GitHub API rate limits with frequent use.
26+
For production use, enable <strong>historical mode</strong> (<code>ENABLE_HISTORICAL_MODE=true</code>) to cache data in the database and avoid repeated API calls.
27+
</v-alert>
28+
1529
<!-- Team selector -->
1630
<v-card variant="outlined" class="mx-4 mb-2 pa-3" density="compact">
1731
<div class="d-flex align-center gap-2">
@@ -475,6 +489,10 @@ export default defineComponent({
475489
setup(props) {
476490
const theme = useTheme()
477491
const isDark = computed(() => theme.global.current.value.dark)
492+
const config = useRuntimeConfig()
493+
const isHistoricalMode = computed(() =>
494+
config.public?.enableHistoricalMode === true || config.public?.enableHistoricalMode === 'true'
495+
)
478496
479497
const availableTeams = ref<Team[]>([])
480498
const selectedTeams = ref<string[]>([])
@@ -1071,6 +1089,7 @@ export default defineComponent({
10711089
clearSelection,
10721090
getTeamDetailUrl,
10731091
isDark,
1092+
isHistoricalMode,
10741093
dateRangeDesc: props.dateRangeDescription
10751094
}
10761095
}

app/model/Options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ export class Options {
370370
if (!this.githubEnt || !this.githubTeam) {
371371
throw new Error('GitHub enterprise and team must be set for enterprise scope');
372372
}
373-
return `${baseUrl}/enterprises/${this.githubEnt}/teams/${this.githubTeam}/members`;
373+
return `${baseUrl}/enterprises/${this.githubEnt}/teams/${this.githubTeam}/memberships`;
374374

375375
default:
376376
throw new Error(`Invalid scope: ${this.scope}`);

app/utils/tabUtils.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ export function applyHiddenTabs(tabItems: string[], hiddenTabsConfig: string): s
1010
}
1111

1212
/**
13-
* Auto-hides the "teams" tab when historical mode is disabled.
14-
* Team metrics require the user_day_metrics DB table; without it the teams
15-
* tab falls back to org-wide data and shows identical (incorrect) data for every team.
13+
* Historical mode filter — previously hid the "teams" tab when historical
14+
* mode was disabled (team metrics required the user_day_metrics DB table).
15+
*
16+
* Team metrics now work in direct API mode (by fetching enterprise/org
17+
* user-level records and filtering by team membership), so the teams tab
18+
* is always visible regardless of historical mode.
19+
*
20+
* Kept for backward compatibility; callers don't need to change.
1621
*/
17-
export function applyHistoricalModeFilter(tabItems: string[], enableHistoricalMode: boolean | string): string[] {
18-
const historicalMode = enableHistoricalMode === true || enableHistoricalMode === 'true'
19-
if (!historicalMode && tabItems.includes('teams')) {
20-
return tabItems.filter((t: string) => t !== 'teams')
21-
}
22-
return tabItems
22+
export function applyHistoricalModeFilter(tabItems: string[], _enableHistoricalMode: boolean | string): string[] {
23+
return tabItems;
2324
}

server/api/seats.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,15 @@ export interface TeamMember {
2929
}
3030

3131
/**
32-
* Fetch all members of a team (org team scope) handling GitHub API pagination.
33-
* For now this is limited to organization team scopes. Enterprise team member
34-
* listing requires resolving the parent organization; that enhancement can be
35-
* added later if needed.
32+
* Fetch all members of a team handling GitHub API pagination.
33+
* Supports both organization teams (via /members) and enterprise teams
34+
* (via /memberships with X-GitHub-Api-Version: 2026-03-10).
3635
*
3736
* @param options Options containing scope/org/team information
3837
* @param headers Headers (with Authorization) forwarded from the incoming request
3938
* @returns Array of team member objects returned by the GitHub API
4039
*/
4140
export async function fetchAllTeamMembers(options: Options, headers: HeadersInit): Promise<TeamMember[]> {
42-
// Only proceed when an organization + team slug are both present
4341
if (!options.githubTeam) {
4442
return [];
4543
}
@@ -49,27 +47,58 @@ export async function fetchAllTeamMembers(options: Options, headers: HeadersInit
4947
let page = 1;
5048
const members: TeamMember[] = [];
5149

52-
/*
53-
* Loop until an empty page (or a short page) is returned. We purposely do
54-
* not rely on the Link header to keep the implementation simple & robust
55-
* under mocking. If rate limiting becomes a concern this can be replaced
56-
* with Link header parsing.
57-
*/
50+
// Build headers: add API version for enterprise team memberships
51+
const fetchHeaders: Record<string, string> = {};
52+
if (headers instanceof Headers) {
53+
for (const [key, value] of headers.entries()) {
54+
fetchHeaders[key] = value;
55+
}
56+
} else if (typeof headers === 'object') {
57+
Object.assign(fetchHeaders, headers);
58+
}
59+
if (options.scope === 'enterprise') {
60+
delete fetchHeaders['x-github-api-version'];
61+
fetchHeaders['X-GitHub-Api-Version'] = '2026-03-10';
62+
}
63+
5864
while (true) {
5965
const pageData = await $fetch<TeamMember[]>(membersUrl, {
60-
headers,
66+
headers: fetchHeaders,
6167
params: { per_page: perPage, page }
6268
});
6369

6470
if (!Array.isArray(pageData) || pageData.length === 0) break;
65-
members.push(...pageData);
71+
// Normalize: enterprise /memberships may nest user data under a `user` property
72+
for (const item of pageData) {
73+
const member = normalizeTeamMember(item);
74+
if (member) members.push(member);
75+
}
6676
if (pageData.length < perPage) break; // last page
6777
page += 1;
6878
}
6979

7080
return members;
7181
}
7282

83+
/**
84+
* Normalize a team member response item into {login, id}.
85+
* Handles both flat user objects and potentially nested membership objects.
86+
*/
87+
function normalizeTeamMember(item: Record<string, unknown>): TeamMember | null {
88+
// Flat user object (standard shape from both /members and /memberships)
89+
if (typeof item.login === 'string' && typeof item.id === 'number') {
90+
return item as TeamMember;
91+
}
92+
// Nested membership object (defensive: { user: { login, id } })
93+
if (item.user && typeof item.user === 'object') {
94+
const user = item.user as Record<string, unknown>;
95+
if (typeof user.login === 'string' && typeof user.id === 'number') {
96+
return user as TeamMember;
97+
}
98+
}
99+
return null;
100+
}
101+
73102
/**
74103
* Deduplicates seats by user ID, keeping the seat with the most recent activity.
75104
* This handles enterprise scenarios where users are assigned to multiple organizations.

server/api/teams.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,26 @@ export async function getTeams(event: H3Event<EventHandlerRequest>): Promise<Tea
7474
// Build base URL based on scope
7575
const baseUrl = options.getTeamsApiUrl()
7676

77+
// Build headers: start from auth middleware headers, add API version for enterprise teams
78+
const fetchHeaders: Record<string, string> = {}
79+
if (event.context.headers instanceof Headers) {
80+
for (const [key, value] of event.context.headers.entries()) {
81+
fetchHeaders[key] = value
82+
}
83+
}
84+
if (options.scope === 'enterprise') {
85+
delete fetchHeaders['x-github-api-version']
86+
fetchHeaders['X-GitHub-Api-Version'] = '2026-03-10'
87+
}
88+
7789
const allTeams: Team[] = []
7890
let nextUrl: string | null = `${baseUrl}?per_page=100`
7991
let page = 1
8092

8193
while (nextUrl) {
8294
logger.info(`Fetching teams page ${page} from ${nextUrl}`)
8395
const res = await $fetch.raw(nextUrl, {
84-
headers: event.context.headers
96+
headers: fetchHeaders
8597
})
8698

8799
const data = res._data as GitHubTeam[]

server/services/user-metrics-aggregator.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,13 @@ export function aggregateTeamMetrics(
3737
userRecords: UserDayRecord[],
3838
teamLogins: Set<string>
3939
): OrgReport {
40-
// Filter to only records for team members
41-
const teamRecords = userRecords.filter(r => teamLogins.has(r.user_login));
40+
// Build case-insensitive login set for robust matching
41+
const normalizedLogins = new Set(Array.from(teamLogins).map(l => l.toLowerCase()));
42+
43+
// Filter to only records for team members (case-insensitive login match)
44+
const teamRecords = userRecords.filter(r =>
45+
r.user_login && normalizedLogins.has(r.user_login.toLowerCase())
46+
);
4247

4348
// Group by day
4449
const byDay = new Map<string, UserDayRecord[]>();

shared/utils/metrics-util-v2.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,23 @@ export async function getMetricsDataV2(event: H3Event<EventHandlerRequest>): Pro
235235
});
236236
}
237237

238+
// Team-scoped direct API path: fetch user-level records, filter by team, aggregate
239+
if (options.githubTeam) {
240+
logger.info(`Direct API team path: resolving members for team "${options.githubTeam}" in ${identifier}`);
241+
const teamMembers = await fetchAllTeamMembers(options, event.context.headers);
242+
if (teamMembers.length === 0) {
243+
logger.info('No team members found — returning empty metrics');
244+
return { metrics: [], reportData: [] };
245+
}
246+
const teamLogins = new Set(teamMembers.map(m => m.login));
247+
248+
const request: MetricsReportRequest = { scope: options.scope!, identifier };
249+
const userDayRecords = await fetchRawUserDayRecords(request, event.context.headers);
250+
logger.info(`Aggregating team metrics from ${userDayRecords.length} user-day records (${teamMembers.length} team members)`);
251+
const report = aggregateTeamMetrics(userDayRecords, teamLogins);
252+
return buildFilteredResult(report, options);
253+
}
254+
238255
logger.info('Using new Copilot Metrics API (direct, no DB)');
239256
const result = await fetchFromNewApi(options, event.context.headers);
240257
return sortMetricsDataResult({

tests/MainComponent.hiddenTabs.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,18 @@ describe('MainComponent hidden tabs filtering', () => {
7272

7373
// Historical mode tests
7474
describe('auto-hide teams tab based on historical mode', () => {
75-
test('should hide teams tab when historical mode is false', () => {
75+
test('should always keep teams tab regardless of historical mode (teams now work in direct API mode)', () => {
7676
const tabs = ['organization', 'teams', ...ALL_BASE_TABS]
7777
const result = applyHistoricalModeFilter(tabs, false)
78-
expect(result).not.toContain('teams')
78+
expect(result).toContain('teams')
7979
expect(result).toContain('organization')
8080
expect(result).toContain('languages')
8181
})
8282

83-
test('should hide teams tab when historical mode is string "false"', () => {
83+
test('should keep teams tab when historical mode is string "false"', () => {
8484
const tabs = ['organization', 'teams', ...ALL_BASE_TABS]
8585
const result = applyHistoricalModeFilter(tabs, 'false')
86-
expect(result).not.toContain('teams')
86+
expect(result).toContain('teams')
8787
})
8888

8989
test('should keep teams tab when historical mode is true', () => {
@@ -98,10 +98,10 @@ describe('MainComponent hidden tabs filtering', () => {
9898
expect(result).toContain('teams')
9999
})
100100

101-
test('should not affect other tabs when hiding teams', () => {
101+
test('should not affect other tabs', () => {
102102
const tabs = ['organization', 'teams', ...ALL_BASE_TABS]
103103
const result = applyHistoricalModeFilter(tabs, false)
104-
expect(result).toHaveLength(tabs.length - 1)
104+
expect(result).toHaveLength(tabs.length)
105105
ALL_BASE_TABS.forEach(tab => expect(result).toContain(tab))
106106
})
107107

tests/Options.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,55 @@ describe('Options', () => {
602602
})
603603
})
604604

605+
describe('getTeamMembersApiUrl', () => {
606+
test('generates correct URL for organization scope', () => {
607+
const options = new Options({
608+
scope: 'organization',
609+
githubOrg: 'test-org',
610+
githubTeam: 'test-team'
611+
})
612+
613+
expect(options.getTeamMembersApiUrl()).toBe('https://api.github.com/orgs/test-org/teams/test-team/members')
614+
})
615+
616+
test('generates correct URL for enterprise scope (uses /memberships)', () => {
617+
const options = new Options({
618+
scope: 'enterprise',
619+
githubEnt: 'test-ent',
620+
githubTeam: 'test-team'
621+
})
622+
623+
expect(options.getTeamMembersApiUrl()).toBe('https://api.github.com/enterprises/test-ent/teams/test-team/memberships')
624+
})
625+
626+
test('throws error for organization scope without org or team', () => {
627+
const options = new Options({
628+
scope: 'organization',
629+
githubOrg: 'test-org'
630+
})
631+
632+
expect(() => options.getTeamMembersApiUrl()).toThrow('GitHub organization and team must be set for organization scope')
633+
})
634+
635+
test('throws error for enterprise scope without ent or team', () => {
636+
const options = new Options({
637+
scope: 'enterprise',
638+
githubEnt: 'test-ent'
639+
})
640+
641+
expect(() => options.getTeamMembersApiUrl()).toThrow('GitHub enterprise and team must be set for enterprise scope')
642+
})
643+
644+
test('throws error for invalid scope', () => {
645+
const options = new Options({
646+
scope: 'invalid-scope' as Scope,
647+
githubTeam: 'test-team'
648+
})
649+
650+
expect(() => options.getTeamMembersApiUrl()).toThrow('Invalid scope: invalid-scope')
651+
})
652+
})
653+
605654
describe('getMockDataPath', () => {
606655
test('returns correct path for organization scope', () => {
607656
const options1 = new Options({ scope: 'organization' })

tests/user-metrics-aggregator.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,4 +318,17 @@ describe('aggregateTeamMetrics', () => {
318318
expect(result.day_totals[0].daily_active_users).toBe(1);
319319
expect(result.day_totals[1].daily_active_users).toBe(1);
320320
});
321+
322+
it('matches team members case-insensitively', () => {
323+
const records = [
324+
makeUser('Alice', 1, '2026-02-10'),
325+
makeUser('BOB', 2, '2026-02-10'),
326+
makeUser('charlie', 3, '2026-02-10'),
327+
];
328+
// Team logins in different casing than user records
329+
const result = aggregateTeamMetrics(records, new Set(['alice', 'bob']));
330+
331+
expect(result.day_totals).toHaveLength(1);
332+
expect(result.day_totals[0].daily_active_users).toBe(2);
333+
});
321334
});

0 commit comments

Comments
 (0)