From dc7f54b99ad295d6b952b3642d180cc53acb65a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:08:14 +0000 Subject: [PATCH 1/3] Initial plan From 3275eb1420d4a4e748507b1a37fa4272c59c15aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:13:23 +0000 Subject: [PATCH 2/3] Initial analysis and plan for deduplicating seats in enterprise Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24f022ae..04bcac48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-metrics-viewer", - "version": "2.0.4", + "version": "2.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-metrics-viewer", - "version": "2.0.4", + "version": "2.0.5", "hasInstallScript": true, "dependencies": { "@nuxt/eslint": "^0.7.4", From ccbc7183b930e9b50d071b682007445028102cd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Jul 2025 20:22:26 +0000 Subject: [PATCH 3/3] Implement seat deduplication for GitHub Enterprise with multiple organizations Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> --- ...terprise_seats_response_sample.json.backup | 83 +++++++++ .../enterprise_seats_with_duplicates.json | 173 ++++++++++++++++++ server/api/seats.ts | 43 ++++- tests/seats-api-integration.nuxt.spec.ts | 126 +++++++++++++ tests/seats-deduplication.nuxt.spec.ts | 164 +++++++++++++++++ 5 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 public/mock-data/enterprise_seats_response_sample.json.backup create mode 100644 public/mock-data/enterprise_seats_with_duplicates.json create mode 100644 tests/seats-api-integration.nuxt.spec.ts create mode 100644 tests/seats-deduplication.nuxt.spec.ts diff --git a/public/mock-data/enterprise_seats_response_sample.json.backup b/public/mock-data/enterprise_seats_response_sample.json.backup new file mode 100644 index 00000000..c5ebf025 --- /dev/null +++ b/public/mock-data/enterprise_seats_response_sample.json.backup @@ -0,0 +1,83 @@ +{ + "total_seats": 3, + "seats": [ + { + "created_at": "2021-08-03T18:00:00-06:00", + "updated_at": "2021-09-23T15:00:00-06:00", + "pending_cancellation_date": null, + "last_activity_at": "2021-10-14T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": { + "login": "octocat_byEnterprise", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "assigning_team": { + "id": 1, + "node_id": "MDQ6VGVhbTE=", + "url": "https://api.github.com/teams/1", + "html_url": "https://github.com/orgs/github/teams/justice-league", + "name": "Justice League", + "slug": "justice-league", + "description": "A great team.", + "privacy": "closed", + "notification_setting": "notifications_enabled", + "permission": "admin", + "members_url": "https://api.github.com/teams/1/members{/member}", + "repositories_url": "https://api.github.com/teams/1/repos", + "parent": null + } + }, + { + "created_at": "2021-09-23T18:00:00-06:00", + "updated_at": "2021-09-23T15:00:00-06:00", + "pending_cancellation_date": "2021-11-01", + "last_activity_at": "2021-10-13T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": { + "login": "octokitten", + "id": 1, + "node_id": "MDQ76VNlcjE=", + "avatar_url": "https://github.com/images/error/octokitten_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octokitten", + "html_url": "https://github.com/octokitten", + "followers_url": "https://api.github.com/users/octokitten/followers", + "following_url": "https://api.github.com/users/octokitten/following{/other_user}", + "gists_url": "https://api.github.com/users/octokitten/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octokitten/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octokitten/subscriptions", + "organizations_url": "https://api.github.com/users/octokitten/orgs", + "repos_url": "https://api.github.com/users/octokitten/repos", + "events_url": "https://api.github.com/users/octokitten/events{/privacy}", + "received_events_url": "https://api.github.com/users/octokitten/received_events", + "type": "User", + "site_admin": false + } + }, + { + "created_at": "2021-09-23T18:00:00-06:00", + "updated_at": "2021-09-23T15:00:00-06:00", + "pending_cancellation_date": "2021-11-01", + "last_activity_at": "2021-10-12T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": null, + "assigning_team": null + } + ] + } \ No newline at end of file diff --git a/public/mock-data/enterprise_seats_with_duplicates.json b/public/mock-data/enterprise_seats_with_duplicates.json new file mode 100644 index 00000000..748c6374 --- /dev/null +++ b/public/mock-data/enterprise_seats_with_duplicates.json @@ -0,0 +1,173 @@ +{ + "total_seats": 2, + "seats": [ + { + "created_at": "2021-08-03T18:00:00-06:00", + "updated_at": "2021-09-23T15:00:00-06:00", + "pending_cancellation_date": null, + "last_activity_at": "2021-10-14T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "assigning_team": { + "id": 1, + "node_id": "MDQ6VGVhbTE=", + "url": "https://api.github.com/teams/1", + "html_url": "https://github.com/orgs/github/teams/justice-league", + "name": "Team Alpha", + "slug": "team-alpha", + "description": "A great team.", + "privacy": "closed", + "notification_setting": "notifications_enabled", + "permission": "admin", + "members_url": "https://api.github.com/teams/1/members{/member}", + "repositories_url": "https://api.github.com/teams/1/repos", + "parent": null + } + }, + { + "created_at": "2021-08-03T18:00:00-06:00", + "updated_at": "2021-09-23T15:00:00-06:00", + "pending_cancellation_date": null, + "last_activity_at": "2021-10-16T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "assigning_team": { + "id": 2, + "node_id": "MDQ6VGVhbTI=", + "url": "https://api.github.com/teams/2", + "html_url": "https://github.com/orgs/github/teams/beta-team", + "name": "Team Beta", + "slug": "team-beta", + "description": "Another great team.", + "privacy": "closed", + "notification_setting": "notifications_enabled", + "permission": "admin", + "members_url": "https://api.github.com/teams/2/members{/member}", + "repositories_url": "https://api.github.com/teams/2/repos", + "parent": null + } + }, + { + "created_at": "2021-08-04T18:00:00-06:00", + "updated_at": "2021-09-24T15:00:00-06:00", + "pending_cancellation_date": null, + "last_activity_at": "2021-10-13T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": { + "login": "octokitten", + "id": 2, + "node_id": "MDQ76VNlcjI=", + "avatar_url": "https://github.com/images/error/octokitten_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octokitten", + "html_url": "https://github.com/octokitten", + "followers_url": "https://api.github.com/users/octokitten/followers", + "following_url": "https://api.github.com/users/octokitten/following{/other_user}", + "gists_url": "https://api.github.com/users/octokitten/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octokitten/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octokitten/subscriptions", + "organizations_url": "https://api.github.com/users/octokitten/orgs", + "repos_url": "https://api.github.com/users/octokitten/repos", + "events_url": "https://api.github.com/users/octokitten/events{/privacy}", + "received_events_url": "https://api.github.com/users/octokitten/received_events", + "type": "User", + "site_admin": false + }, + "assigning_team": { + "id": 1, + "node_id": "MDQ6VGVhbTE=", + "url": "https://api.github.com/teams/1", + "html_url": "https://github.com/orgs/github/teams/justice-league", + "name": "Team Alpha", + "slug": "team-alpha", + "description": "A great team.", + "privacy": "closed", + "notification_setting": "notifications_enabled", + "permission": "admin", + "members_url": "https://api.github.com/teams/1/members{/member}", + "repositories_url": "https://api.github.com/teams/1/repos", + "parent": null + } + }, + { + "created_at": "2021-08-04T18:00:00-06:00", + "updated_at": "2021-09-24T15:00:00-06:00", + "pending_cancellation_date": null, + "last_activity_at": "2021-10-15T00:53:32-06:00", + "last_activity_editor": "vscode/1.77.3/copilot/1.86.82", + "assignee": { + "login": "octokitten", + "id": 2, + "node_id": "MDQ76VNlcjI=", + "avatar_url": "https://github.com/images/error/octokitten_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octokitten", + "html_url": "https://github.com/octokitten", + "followers_url": "https://api.github.com/users/octokitten/followers", + "following_url": "https://api.github.com/users/octokitten/following{/other_user}", + "gists_url": "https://api.github.com/users/octokitten/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octokitten/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octokitten/subscriptions", + "organizations_url": "https://api.github.com/users/octokitten/orgs", + "repos_url": "https://api.github.com/users/octokitten/repos", + "events_url": "https://api.github.com/users/octokitten/events{/privacy}", + "received_events_url": "https://api.github.com/users/octokitten/received_events", + "type": "User", + "site_admin": false + }, + "assigning_team": { + "id": 2, + "node_id": "MDQ6VGVhbTI=", + "url": "https://api.github.com/teams/2", + "html_url": "https://github.com/orgs/github/teams/beta-team", + "name": "Team Beta", + "slug": "team-beta", + "description": "Another great team.", + "privacy": "closed", + "notification_setting": "notifications_enabled", + "permission": "admin", + "members_url": "https://api.github.com/teams/2/members{/member}", + "repositories_url": "https://api.github.com/teams/2/repos", + "parent": null + } + } + ] +} \ No newline at end of file diff --git a/server/api/seats.ts b/server/api/seats.ts index 7774a101..e775b7f2 100644 --- a/server/api/seats.ts +++ b/server/api/seats.ts @@ -1,8 +1,39 @@ import { Seat } from "@/model/Seat"; -import type FetchError from 'ofetch'; import { readFileSync } from 'fs'; import { resolve } from 'path'; +/** + * Deduplicates seats by user ID, keeping the seat with the most recent activity. + * This handles enterprise scenarios where users are assigned to multiple organizations. + * @param seats Array of seats to deduplicate + * @returns Array of unique seats + */ +function deduplicateSeats(seats: Seat[]): Seat[] { + const uniqueSeats = new Map(); + + for (const seat of seats) { + // Skip seats with invalid user ID + if (!seat.id || seat.id === 0) { + continue; + } + + const existingSeat = uniqueSeats.get(seat.id); + if (!existingSeat) { + uniqueSeats.set(seat.id, seat); + } else { + // Keep the seat with more recent activity, treating null as earliest date + const seatActivity = seat.last_activity_at || '1970-01-01T00:00:00Z'; + const existingActivity = existingSeat.last_activity_at || '1970-01-01T00:00:00Z'; + + if (seatActivity > existingActivity) { + uniqueSeats.set(seat.id, seat); + } + } + } + + return Array.from(uniqueSeats.values()); +} + export default defineEventHandler(async (event) => { const logger = console; @@ -29,9 +60,12 @@ export default defineEventHandler(async (event) => { const data = readFileSync(path, 'utf8'); const dataJson = JSON.parse(data); const seatsData = dataJson.seats.map((item: unknown) => new Seat(item)); + + // Deduplicate seats by user ID to handle enterprise scenarios where users are assigned to multiple organizations + const deduplicatedSeats = deduplicateSeats(seatsData); logger.info('Using mocked data'); - return seatsData; + return deduplicatedSeats; } if (!event.context.headers.has('Authorization')) { @@ -76,5 +110,8 @@ export default defineEventHandler(async (event) => { seatsData = seatsData.concat(response.seats.map((item: unknown) => new Seat(item))); } - return seatsData; + // Deduplicate seats by user ID to handle enterprise scenarios where users are assigned to multiple organizations + const deduplicatedSeats = deduplicateSeats(seatsData); + + return deduplicatedSeats; }) \ No newline at end of file diff --git a/tests/seats-api-integration.nuxt.spec.ts b/tests/seats-api-integration.nuxt.spec.ts new file mode 100644 index 00000000..36993c6f --- /dev/null +++ b/tests/seats-api-integration.nuxt.spec.ts @@ -0,0 +1,126 @@ +// @vitest-environment nuxt +import { describe, test, expect } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' +import { Seat } from '@/model/Seat' + +describe('Enterprise Seats API Integration', () => { + test('deduplicates seats from mock data with duplicates', () => { + // Read the mock data with duplicates + const mockDataPath = resolve('public/mock-data/enterprise_seats_with_duplicates.json') + const data = readFileSync(mockDataPath, 'utf8') + const mockData = JSON.parse(data) + + // Create seats from mock data + const seats = mockData.seats.map((item: unknown) => new Seat(item)) + + // Verify we have duplicates in the raw data + expect(seats).toHaveLength(4) // 4 seat records + expect(mockData.total_seats).toBe(2) // But only 2 unique users + + // Apply the same deduplication logic as the API + const deduplicateSeats = (seats: Seat[]): Seat[] => { + const uniqueSeats = new Map() + + for (const seat of seats) { + // Skip seats with invalid user ID + if (!seat.id || seat.id === 0) { + continue + } + + const existingSeat = uniqueSeats.get(seat.id) + if (!existingSeat) { + uniqueSeats.set(seat.id, seat) + } else { + // Keep the seat with more recent activity, treating null as earliest date + const seatActivity = seat.last_activity_at || '1970-01-01T00:00:00Z' + const existingActivity = existingSeat.last_activity_at || '1970-01-01T00:00:00Z' + + if (seatActivity > existingActivity) { + uniqueSeats.set(seat.id, seat) + } + } + } + + return Array.from(uniqueSeats.values()) + } + + const deduplicatedSeats = deduplicateSeats(seats) + + // After deduplication, should have only 2 unique users + expect(deduplicatedSeats).toHaveLength(2) + + // Verify user 1 (octocat) has the seat with most recent activity (Team Beta) + const user1 = deduplicatedSeats.find(s => s.id === 1) + expect(user1).toBeDefined() + expect(user1!.login).toBe('octocat') + expect(user1!.team).toBe('Team Beta') // Should be the one with more recent activity + expect(user1!.last_activity_at).toBe('2021-10-16T00:53:32-06:00') + + // Verify user 2 (octokitten) has the seat with most recent activity (Team Beta) + const user2 = deduplicatedSeats.find(s => s.id === 2) + expect(user2).toBeDefined() + expect(user2!.login).toBe('octokitten') + expect(user2!.team).toBe('Team Beta') // Should be the one with more recent activity + expect(user2!.last_activity_at).toBe('2021-10-15T00:53:32-06:00') + + // Verify that the deduplication matches the total_seats count + expect(deduplicatedSeats.length).toBe(mockData.total_seats) + }) + + test('handles seats with no assignee correctly', () => { + // Test scenario with null assignee (should be filtered out) + const seatsWithNullAssignee = [ + { + created_at: '2021-08-03T18:00:00-06:00', + last_activity_at: '2021-10-14T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82', + assignee: null, + assigning_team: { name: 'Team A' } + }, + { + created_at: '2021-08-04T18:00:00-06:00', + last_activity_at: '2021-10-15T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82', + assignee: { login: 'validuser', id: 1 }, + assigning_team: { name: 'Team B' } + } + ] + + const seats = seatsWithNullAssignee.map(data => new Seat(data)) + + // Apply deduplication + const deduplicateSeats = (seats: Seat[]): Seat[] => { + const uniqueSeats = new Map() + + for (const seat of seats) { + // Skip seats with invalid user ID + if (!seat.id || seat.id === 0) { + continue + } + + const existingSeat = uniqueSeats.get(seat.id) + if (!existingSeat) { + uniqueSeats.set(seat.id, seat) + } else { + // Keep the seat with more recent activity, treating null as earliest date + const seatActivity = seat.last_activity_at || '1970-01-01T00:00:00Z' + const existingActivity = existingSeat.last_activity_at || '1970-01-01T00:00:00Z' + + if (seatActivity > existingActivity) { + uniqueSeats.set(seat.id, seat) + } + } + } + + return Array.from(uniqueSeats.values()) + } + + const deduplicatedSeats = deduplicateSeats(seats) + + // Should have only 1 seat (the one with null assignee is filtered out) + expect(deduplicatedSeats).toHaveLength(1) + expect(deduplicatedSeats[0].login).toBe('validuser') + expect(deduplicatedSeats[0].id).toBe(1) + }) +}) \ No newline at end of file diff --git a/tests/seats-deduplication.nuxt.spec.ts b/tests/seats-deduplication.nuxt.spec.ts new file mode 100644 index 00000000..d46d2255 --- /dev/null +++ b/tests/seats-deduplication.nuxt.spec.ts @@ -0,0 +1,164 @@ +// @vitest-environment nuxt +import { describe, test, expect } from 'vitest' +import { Seat } from '@/model/Seat' + +// Mock the seats API response with duplicates +const mockSeatsWithDuplicates = [ + { + assignee: { login: 'user1', id: 1 }, + assigning_team: { name: 'Team A' }, + created_at: '2021-08-03T18:00:00-06:00', + last_activity_at: '2021-10-14T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + }, + { + assignee: { login: 'user1', id: 1 }, + assigning_team: { name: 'Team B' }, + created_at: '2021-08-04T18:00:00-06:00', + last_activity_at: '2021-10-15T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + }, + { + assignee: { login: 'user2', id: 2 }, + assigning_team: { name: 'Team A' }, + created_at: '2021-08-03T18:00:00-06:00', + last_activity_at: '2021-10-12T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + } +] + +describe('Seat Deduplication', () => { + test('deduplicates seats by user ID keeping most recent activity', () => { + const seats = mockSeatsWithDuplicates.map(data => new Seat(data)) + + // Create a simple deduplication function that we'll implement in the API + const deduplicate = (seats: Seat[]): Seat[] => { + const uniqueSeats = new Map() + + for (const seat of seats) { + const existingSeat = uniqueSeats.get(seat.id) + if (!existingSeat) { + uniqueSeats.set(seat.id, seat) + } else { + // Keep the one with more recent activity + if (seat.last_activity_at > existingSeat.last_activity_at) { + uniqueSeats.set(seat.id, seat) + } + } + } + + return Array.from(uniqueSeats.values()) + } + + const deduplicatedSeats = deduplicate(seats) + + // Should have 2 unique users + expect(deduplicatedSeats).toHaveLength(2) + + // Should have user1 with most recent activity (from Team B) + const user1 = deduplicatedSeats.find(s => s.id === 1) + expect(user1).toBeDefined() + expect(user1!.login).toBe('user1') + expect(user1!.team).toBe('Team B') // Should be the one with more recent activity + expect(user1!.last_activity_at).toBe('2021-10-15T00:53:32-06:00') + + // Should have user2 + const user2 = deduplicatedSeats.find(s => s.id === 2) + expect(user2).toBeDefined() + expect(user2!.login).toBe('user2') + expect(user2!.team).toBe('Team A') + }) + + test('handles seats with same user ID but different activity dates', () => { + const duplicateSeats = [ + { + assignee: { login: 'user1', id: 1 }, + assigning_team: { name: 'Old Team' }, + created_at: '2021-08-01T18:00:00-06:00', + last_activity_at: '2021-10-10T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + }, + { + assignee: { login: 'user1', id: 1 }, + assigning_team: { name: 'New Team' }, + created_at: '2021-08-05T18:00:00-06:00', + last_activity_at: '2021-10-20T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + } + ] + + const seats = duplicateSeats.map(data => new Seat(data)) + + const deduplicate = (seats: Seat[]): Seat[] => { + const uniqueSeats = new Map() + + for (const seat of seats) { + const existingSeat = uniqueSeats.get(seat.id) + if (!existingSeat) { + uniqueSeats.set(seat.id, seat) + } else { + // Keep the one with more recent activity + if (seat.last_activity_at > existingSeat.last_activity_at) { + uniqueSeats.set(seat.id, seat) + } + } + } + + return Array.from(uniqueSeats.values()) + } + + const deduplicatedSeats = deduplicate(seats) + + expect(deduplicatedSeats).toHaveLength(1) + expect(deduplicatedSeats[0].team).toBe('New Team') + expect(deduplicatedSeats[0].last_activity_at).toBe('2021-10-20T00:53:32-06:00') + }) + + test('handles seats with null last_activity_at', () => { + const seatsWithNullActivity = [ + { + assignee: { login: 'user1', id: 1 }, + assigning_team: { name: 'Team A' }, + created_at: '2021-08-03T18:00:00-06:00', + last_activity_at: null, + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + }, + { + assignee: { login: 'user1', id: 1 }, + assigning_team: { name: 'Team B' }, + created_at: '2021-08-04T18:00:00-06:00', + last_activity_at: '2021-10-15T00:53:32-06:00', + last_activity_editor: 'vscode/1.77.3/copilot/1.86.82' + } + ] + + const seats = seatsWithNullActivity.map(data => new Seat(data)) + + const deduplicate = (seats: Seat[]): Seat[] => { + const uniqueSeats = new Map() + + for (const seat of seats) { + const existingSeat = uniqueSeats.get(seat.id) + if (!existingSeat) { + uniqueSeats.set(seat.id, seat) + } else { + // Keep the one with more recent activity, treat null as earliest date + const seatActivity = seat.last_activity_at || '1970-01-01T00:00:00Z' + const existingActivity = existingSeat.last_activity_at || '1970-01-01T00:00:00Z' + + if (seatActivity > existingActivity) { + uniqueSeats.set(seat.id, seat) + } + } + } + + return Array.from(uniqueSeats.values()) + } + + const deduplicatedSeats = deduplicate(seats) + + expect(deduplicatedSeats).toHaveLength(1) + expect(deduplicatedSeats[0].team).toBe('Team B') + expect(deduplicatedSeats[0].last_activity_at).toBe('2021-10-15T00:53:32-06:00') + }) +}) \ No newline at end of file