From 7852290c2a8745e759ed891fa52cd86843a23072 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:17:08 +0000 Subject: [PATCH 1/3] feat: add personal copilot analytics view Agent-Logs-Url: https://github.com/thinkdj/gh.devcp/sessions/c04af5d6-e865-425a-bcac-b0193097cebb Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- README.md | 44 +- src/App.test.tsx | 64 ++- src/App.tsx | 88 ++-- src/components/CopilotMetricsPanel.tsx | 582 ++++++++++++++++--------- src/components/TokenSettings.tsx | 5 +- src/github-api.test.ts | 73 +++- src/github-api.ts | 90 +++- src/types.ts | 51 +++ 8 files changed, 722 insertions(+), 275 deletions(-) diff --git a/README.md b/README.md index 89f45af..1fc5a36 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ When notifications become noisy, this dashboard gives a bird's-eye view of PRs t - **Theme palettes + dark / light** — Slate (default), Warm, Ocean, Ember; brightness toggle; preferences in `localStorage` - **My actions** — tab surfacing PRs that need your review and related follow-ups - **Branch analysis** — optional per-repo run: stale / merged / active branch table, search, copy branch name, GitHub links -- **Copilot Analytics** — org-level Copilot usage metrics: seat utilisation, engagement rate, acceptance rate, daily trend chart, language/editor/model breakdowns (requires `manage_billing:copilot` or `read:org` token scope) +- **Copilot Analytics** — org-level Copilot usage metrics plus personal-account Copilot Pro billing analytics and premium-request usage - **PR search and filtering** — search by title/author/number, filter by author/repo/label dropdowns, hide drafts / Copilot / yours - **Sortable columns** — sort by title, repo, author, opened, updated, checks, Copilot, merge safety, pushes, comments - **Relative time** — e.g. "5 minutes ago" (full datetime on hover); color-coded by age where relevant @@ -85,30 +85,50 @@ npm install npm run dev ``` -Open http://localhost:5173, click **Set token**, and paste a GitHub fine-grained Personal Access Token with read-only access to the repositories you want to monitor (pull requests, checks, and metadata). +Open http://localhost:5173, click **Set token**, and paste a GitHub token with the minimum access needed for the tabs you plan to use. ### Required token permissions -Use a **fine-grained Personal Access Token** scoped to the repositories (or the entire account) you want to monitor. +Use a **fine-grained Personal Access Token** scoped to the repositories (or the entire account) you want to monitor for the PR, branch, and most dashboard features. -| Permission | Access | Used for | -| ----------------- | --------- | ------------------------------------------------ | -| **Pull requests** | Read-only | Fetch open PRs, reviews, and requested reviewers | -| **Checks** | Read-only | Fetch CI check-run statuses for each PR | -| **Metadata** | Read-only | List your repositories in the repo picker | +| Area / Scope | Access | Used for | +| --- | --- | --- | +| **Pull requests** (fine-grained repo permission) | Read-only | Fetch open PRs, review state, requested reviewers, PR metadata, and PR detail panel activity | +| **Checks** (fine-grained repo permission) | Read-only | Fetch CI check-run status for each PR | +| **Metadata** (fine-grained repo permission) | Read-only | List repositories in the repo picker and read basic repo metadata | +| **Contents** (fine-grained repo permission) | Read-only | Recommended for branch analysis on private repos, where branch/compare lookups can otherwise fail with 403 | +| **Issues** or **Pull requests** (fine-grained repo permission) | Read/Write only if you want to post comments | Post a comment from the PR detail panel | +| **Account billing access** (your own account) | Read-only | Personal Copilot analytics in the Copilot tab via `/users/{username}/settings/billing/usage` and `/users/{username}/settings/billing/premium_request/usage` | +| `read:org` (classic PAT or OAuth scope) | Read-only | List org memberships, fetch org Copilot analytics, and fetch org Copilot billing data | +| `manage_billing:copilot` (classic PAT or OAuth scope) | Read-only for this app | Fetch org Copilot billing / seat data and, where allowed by GitHub, org Copilot metrics | Branch listing and comparisons may require **Contents: Read** (or broader repo access) on fine-grained tokens for private repositories—if analysis fails with 403, widen read access for those repos. +If you only want the **Pull requests** and **Branch analysis** tabs, a fine-grained PAT is usually enough. If you also want the **Copilot Analytics** tab for organization data, you will typically need a **classic PAT** (or OAuth token) with the org scopes listed below, because GitHub documents the org Copilot endpoints with `read:org` / `manage_billing:copilot` scopes. + #### Copilot Analytics tab -The **Copilot Analytics** tab uses additional org-level APIs. Grant these **organisation-level** permissions on a **classic PAT** (or add an OAuth scope): +The **Copilot Analytics** tab supports two data sources: + +1. **Personal account analytics** — backed by GitHub billing usage endpoints for the authenticated user. +2. **Organization analytics** — backed by GitHub Copilot org billing / metrics endpoints. + +##### Personal account analytics (GitHub Copilot Pro / Pro+) + +| Permission / access | Access | Used for | +| --- | --- | --- | +| **Read access to your own account billing data** | Read-only | Load Copilot-billed usage and premium request usage for your personal account | + +> **What GitHub exposes for personal accounts:** GitHub does **not** expose the same IDE adoption breakdown API for personal Copilot plans that it exposes for organizations. In this app, the personal view shows the analytics GitHub does expose publicly for personal accounts: billing usage and premium request usage. + +##### Organization analytics | Scope / Permission | Access | Used for | | ------------------------------- | --------- | ----------------------------------------------------------- | -| `read:org` | Read-only | List org memberships, fetch Copilot usage metrics | -| `manage_billing:copilot` | Read-only | Fetch seat breakdown and billing details | +| `read:org` | Read-only | List org memberships, fetch org Copilot metrics, fetch org Copilot billing data | +| `manage_billing:copilot` | Read-only | Fetch org Copilot billing / seat details and org Copilot metrics where permitted | -> **Note:** Copilot usage metrics (`/orgs/{org}/copilot/metrics`) require the token owner to be an **org owner or billing manager** in the target organisation. The panel shows a helpful error banner if the token lacks sufficient access. Personal GitHub accounts are not supported — GitHub does not provide a personal Copilot metrics API. +> **Note:** Organization Copilot analytics require the token owner to be an **org owner** or **billing manager** in the target organization. The panel shows a helpful error banner if the token lacks sufficient access. > **Premium requests percentage** (as shown in GitHub's billing UI) is not available through the public GitHub REST API. The dashboard instead surfaces the acceptance rate, seat utilisation, and engagement rate as the key quality and adoption indicators. diff --git a/src/App.test.tsx b/src/App.test.tsx index ba95287..74a34c8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -22,7 +22,8 @@ vi.mock('./github-api', () => ({ fetchUserOrgs: vi.fn().mockResolvedValue([]), fetchCopilotMetrics: vi.fn().mockResolvedValue([]), fetchCopilotBilling: vi.fn().mockResolvedValue(null), - fetchCopilotOrgData: vi.fn().mockResolvedValue({ org: 'test-org', billing: null, metrics: [], error: undefined }), + fetchCopilotOrgData: vi.fn().mockResolvedValue({ type: 'organization', target: 'org:test-org', org: 'test-org', billing: null, metrics: [], error: undefined }), + fetchCopilotPersonalData: vi.fn().mockResolvedValue({ type: 'personal', target: 'personal:test-user', account: 'test-user', usage: [], premiumRequests: [], error: undefined }), })) import { clearApiCache, fetchCurrentUser, fetchRepoBranches, fetchRepoPullRequests } from './github-api' @@ -959,6 +960,8 @@ describe('PR Pulse – The GitHub Control Panel', () => { it('shows the no-metrics empty state when org has no metrics data', async () => { const { fetchCopilotOrgData: mockCopilotOrgData } = await import('./github-api') ;(mockCopilotOrgData as Mock).mockResolvedValueOnce({ + type: 'organization', + target: 'org:thinkdj', org: 'thinkdj', billing: null, metrics: [], @@ -976,11 +979,8 @@ describe('PR Pulse – The GitHub Control Panel', () => { expect(screen.getByTestId('copilot-metrics-panel')).toBeInTheDocument() }) - it('excludes authenticated user login from copilot org candidates when all repos are personal and fetchUserOrgs returns empty', async () => { - // All repos owned by 'test-user' (the authenticated user mock value). - // fetchUserOrgs returns [] (default mock), so the fallback derivation from - // repo owners must NOT include 'test-user' → empty org list → empty state shown. - const { fetchCopilotOrgData: mockCopilotOrgData } = await import('./github-api') + it('shows personal Copilot analytics when all tracked repos belong to the authenticated user', async () => { + const { fetchCopilotOrgData: mockCopilotOrgData, fetchCopilotPersonalData: mockCopilotPersonalData } = await import('./github-api') localStorage.setItem(TOKEN_KEY, 'ghp_test_token') localStorage.setItem( @@ -993,25 +993,46 @@ describe('PR Pulse – The GitHub Control Panel', () => { fireEvent.click(getCopilotTabBtn()) - // Empty state should appear because 'test-user' is filtered out of org candidates. - expect(await screen.findByTestId('copilot-empty-state')).toBeInTheDocument() + expect(await screen.findByTestId('copilot-metrics-panel')).toBeInTheDocument() - // fetchCopilotOrgData must never have been called — no org to query. + await waitFor(() => { + expect(mockCopilotPersonalData).toHaveBeenCalledWith('test-user', expect.any(String)) + }) expect(mockCopilotOrgData).not.toHaveBeenCalled() + + expect(screen.getByText(/personal copilot pro accounts/i)).toBeInTheDocument() }) - it('includes org owners but not authenticated user login when repos are a mix of personal and org repos', async () => { - // 'test-user/my-project' is personal; 'some-org/their-repo' belongs to an org. - // fetchUserOrgs returns [] so fallback derivation runs. - // Only 'some-org' should end up in copilotOrgs; 'test-user' must be excluded. - const { fetchCopilotOrgData: mockCopilotOrgData } = await import('./github-api') + it('lets the user switch between organization and personal Copilot analytics', async () => { + const { fetchCopilotOrgData: mockCopilotOrgData, fetchCopilotPersonalData: mockCopilotPersonalData } = await import('./github-api') ;(mockCopilotOrgData as Mock).mockResolvedValueOnce({ + type: 'organization', + target: 'org:some-org', org: 'some-org', billing: null, metrics: [], error: undefined, }) + ;(mockCopilotPersonalData as Mock).mockResolvedValueOnce({ + type: 'personal', + target: 'personal:test-user', + account: 'test-user', + usage: [ + { + product: 'GitHub Copilot', + sku: 'copilot_pro', + unitType: 'requests', + pricePerUnit: 0, + grossAmount: 0, + discountAmount: 0, + netAmount: 0, + quantity: 12, + }, + ], + premiumRequests: [], + error: undefined, + }) localStorage.setItem(TOKEN_KEY, 'ghp_test_token') localStorage.setItem( @@ -1029,11 +1050,20 @@ describe('PR Pulse – The GitHub Control Panel', () => { await screen.findByTestId('copilot-metrics-panel') - // The org selector should show 'some-org' but not 'test-user'. await waitFor(() => { expect(mockCopilotOrgData).toHaveBeenCalledWith('some-org', expect.any(String)) - expect(mockCopilotOrgData).not.toHaveBeenCalledWith('test-user', expect.any(String)) }) + + const selector = screen.getByLabelText(/account/i) + expect(within(selector).getByRole('option', { name: /some-org \(organization\)/i })).toBeInTheDocument() + expect(within(selector).getByRole('option', { name: /test-user \(personal\)/i })).toBeInTheDocument() + + fireEvent.change(selector, { target: { value: 'personal:test-user' } }) + + await waitFor(() => { + expect(mockCopilotPersonalData).toHaveBeenCalledWith('test-user', expect.any(String)) + }) + expect(screen.getByText('12')).toBeInTheDocument() }) it('shows usage bars and summary cards when metrics data is available', async () => { @@ -1042,6 +1072,8 @@ describe('PR Pulse – The GitHub Control Panel', () => { ;(mockUserOrgs as Mock).mockResolvedValueOnce(['thinkdj']) ;(mockCopilotOrgData as Mock).mockResolvedValueOnce({ + type: 'organization', + target: 'org:thinkdj', org: 'thinkdj', billing: { seat_breakdown: { total: 10, added_this_cycle: 2, pending_invitation: 1, pending_cancellation: 0, active_this_cycle: 8, inactive_this_cycle: 2 }, diff --git a/src/App.tsx b/src/App.tsx index 49b6d58..c60812b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,10 +16,10 @@ import { Bot, FlaskConical, } from 'lucide-react' -import type { DashboardFilter, PullRequest } from './types' +import type { CopilotAnalyticsTarget, DashboardFilter, PullRequest } from './types' import { loadRepoState, loadToken, loadTheme, loadMode, saveRepoState, saveToken, saveTheme, saveMode } from './storage' import type { Theme, Mode } from './storage' -import { clearApiCache, fetchCopilotOrgData, fetchCurrentUser, fetchRepoBranches, fetchRepoPullRequests, fetchUserOrgs, fetchUserRepos } from './github-api' +import { clearApiCache, fetchCopilotOrgData, fetchCopilotPersonalData, fetchCurrentUser, fetchRepoBranches, fetchRepoPullRequests, fetchUserOrgs, fetchUserRepos } from './github-api' import { formatAgeAgoFromMs } from './utils' import SummaryWidgets from './components/SummaryWidgets' import RepoSelector from './components/RepoSelector' @@ -62,7 +62,7 @@ function App() { const [showRefreshConfirm, setShowRefreshConfirm] = useState(false) const [branchAnalysisTriggered, setBranchAnalysisTriggered] = useState(false) const [branchAnalysisRepos, setBranchAnalysisRepos] = useState([]) - const [selectedCopilotOrgRaw, setSelectedCopilotOrg] = useState('') + const [selectedCopilotTargetRaw, setSelectedCopilotTarget] = useState('') useEffect(() => { applyTheme(theme, mode) }, [theme, mode]) @@ -195,36 +195,52 @@ function App() { return [...fromRepos].sort() }, [repos, userOrgs, orgsIsPending, currentUser]) - // Derive effective selected org: fall back to first org if raw selection is invalid - const selectedCopilotOrg = - selectedCopilotOrgRaw && copilotOrgs.includes(selectedCopilotOrgRaw) - ? selectedCopilotOrgRaw - : (copilotOrgs[0] ?? '') - - const copilotQueryDefs = useMemo( - () => - selectedCopilotOrg + const copilotTargets = useMemo( + () => [ + ...copilotOrgs.map((org) => ({ + id: `org:${org}`, + label: `${org} (Organization)`, + type: 'organization' as const, + })), + ...(!isDemoMode && currentUser ? [{ - queryKey: ['copilot-org', token, selectedCopilotOrg] as const, - queryFn: isDemoMode - ? () => { - const data = demoData?.demoCopilotOrgData ?? [] - return Promise.resolve(data.find((d) => d.org === selectedCopilotOrg) ?? data[0]) - } - : () => fetchCopilotOrgData(selectedCopilotOrg, token), - staleTime: STALE_TIME, - retry: isDemoMode ? 0 : 1, - enabled: !!token && activeView === 'copilot' && !!selectedCopilotOrg, + id: `personal:${currentUser}`, + label: `${currentUser} (Personal)`, + type: 'personal' as const, }] - : [], - [selectedCopilotOrg, token, activeView, STALE_TIME, demoData], + : []), + ], + [copilotOrgs, currentUser], ) - const copilotQueries = useQueries({ queries: copilotQueryDefs }) - const copilotOrgData = copilotQueries.flatMap((q) => (q.data ? [q.data] : [])) - const copilotIsLoading = copilotQueries.some((q) => q.isLoading) - const copilotError = copilotQueries.find((q) => q.error)?.error ?? null - const copilotDataUpdatedAt = Math.max(0, ...copilotQueries.map((q) => q.dataUpdatedAt)) + const selectedCopilotTarget = copilotTargets.find((target) => target.id === selectedCopilotTargetRaw) + ?? copilotTargets[0] + ?? null + + const copilotQuery = useQuery({ + queryKey: ['copilot-account', token, selectedCopilotTarget?.id], + queryFn: isDemoMode + ? () => { + const data = demoData?.demoCopilotOrgData ?? [] + return Promise.resolve(data.find((d) => d.target === selectedCopilotTarget?.id || d.org === selectedCopilotTarget?.id.replace('org:', '')) ?? data[0] ?? null) + } + : () => { + if (!selectedCopilotTarget) return Promise.resolve(null) + return selectedCopilotTarget.type === 'personal' + ? fetchCopilotPersonalData(currentUser, token) + : fetchCopilotOrgData(selectedCopilotTarget.id.replace('org:', ''), token) + }, + staleTime: STALE_TIME, + retry: isDemoMode ? 0 : 1, + enabled: !!token && activeView === 'copilot' && (!!selectedCopilotTarget || isDemoMode), + }) + + const copilotData = copilotQuery.data ?? null + const copilotIsLoading = copilotQuery.isLoading + const copilotError = copilotQuery.error ?? null + const copilotDataUpdatedAt = copilotQuery.dataUpdatedAt + const copilotTargetsLoading = + orgsIsPending || (!isDemoMode && !!token && activeView === 'copilot' && currentUserQuery.isLoading) const prIsLoading = repoQueries.some((q) => q.isLoading) const prError = repoQueries.find((q) => q.error)?.error ?? null @@ -295,7 +311,7 @@ function App() { void queryClient.invalidateQueries({ queryKey: ['repo-prs'] }) void queryClient.invalidateQueries({ queryKey: ['repo-branches'] }) void queryClient.invalidateQueries({ queryKey: ['user-repos'] }) - void queryClient.invalidateQueries({ queryKey: ['copilot-org'] }) + void queryClient.invalidateQueries({ queryKey: ['copilot-account'] }) void queryClient.invalidateQueries({ queryKey: ['user-orgs'] }) } @@ -308,7 +324,7 @@ function App() { clearApiCache() void queryClient.invalidateQueries({ queryKey: ['repo-prs'] }) void queryClient.invalidateQueries({ queryKey: ['repo-branches'] }) - void queryClient.invalidateQueries({ queryKey: ['copilot-org'] }) + void queryClient.invalidateQueries({ queryKey: ['copilot-account'] }) setBranchAnalysisTriggered(false) } @@ -597,12 +613,12 @@ function App() { /> ) : activeView === 'copilot' ? ( ) : ( diff --git a/src/components/CopilotMetricsPanel.tsx b/src/components/CopilotMetricsPanel.tsx index 3ebd0dd..76b7f6d 100644 --- a/src/components/CopilotMetricsPanel.tsx +++ b/src/components/CopilotMetricsPanel.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { BarChart3, Bot, @@ -10,16 +11,17 @@ import { Cpu, TrendingUp, AlertTriangle, + Wallet, } from 'lucide-react' -import type { CopilotDayMetrics, CopilotOrgData } from '../types' +import type { CopilotAccountData, CopilotAnalyticsTarget, CopilotBillingUsageItem, CopilotDayMetrics } from '../types' type CopilotMetricsPanelProps = { - orgData: CopilotOrgData[] + accountData: CopilotAccountData | null isLoading: boolean - orgsLoading: boolean - orgs: string[] - selectedOrg: string - onOrgChange: (org: string) => void + targetsLoading: boolean + targets: CopilotAnalyticsTarget[] + selectedTarget: string + onTargetChange: (target: string) => void error?: string | null } @@ -282,7 +284,7 @@ function UsageBar({ percentage, label }: { percentage: number; label: string }) ) } -function SummaryCard({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: string; sub?: string }) { +function SummaryCard({ icon, label, value, sub }: { icon: ReactNode; label: string; value: string; sub?: string }) { return (
@@ -400,30 +402,100 @@ function BreakdownTable>({ ) } +type AggregatedPersonalUsage = { + totalUsageQuantity: number + totalPremiumRequests: number + totalNetAmount: number + distinctProducts: number + productBreakdown: { name: string; quantity: number; netAmount: number; unitType: string }[] + premiumRequestBreakdown: { name: string; quantity: number; netAmount: number }[] + repositoryBreakdown: { name: string; quantity: number; netAmount: number }[] +} + +function aggregatePersonalUsage( + usage: CopilotBillingUsageItem[], + premiumRequests: CopilotBillingUsageItem[], +): AggregatedPersonalUsage { + const productMap = new Map() + const premiumMap = new Map() + const repoMap = new Map() + + let totalUsageQuantity = 0 + let totalPremiumRequests = 0 + let totalNetAmount = 0 + + for (const item of usage) { + const quantity = item.quantity ?? item.netQuantity ?? item.grossQuantity ?? 0 + totalUsageQuantity += quantity + totalNetAmount += item.netAmount ?? 0 + + const key = `${item.product} · ${item.sku}` + const existing = productMap.get(key) ?? { quantity: 0, netAmount: 0, unitType: item.unitType } + existing.quantity += quantity + existing.netAmount += item.netAmount ?? 0 + productMap.set(key, existing) + + if (item.repositoryName) { + const repoExisting = repoMap.get(item.repositoryName) ?? { quantity: 0, netAmount: 0 } + repoExisting.quantity += quantity + repoExisting.netAmount += item.netAmount ?? 0 + repoMap.set(item.repositoryName, repoExisting) + } + } + + for (const item of premiumRequests) { + const quantity = item.netQuantity ?? item.quantity ?? item.grossQuantity ?? 0 + totalPremiumRequests += quantity + const key = item.model ? `${item.product} · ${item.model}` : `${item.product} · ${item.sku}` + const existing = premiumMap.get(key) ?? { quantity: 0, netAmount: 0 } + existing.quantity += quantity + existing.netAmount += item.netAmount ?? 0 + premiumMap.set(key, existing) + } + + return { + totalUsageQuantity, + totalPremiumRequests, + totalNetAmount, + distinctProducts: productMap.size, + productBreakdown: [...productMap.entries()] + .map(([name, data]) => ({ name, ...data })) + .sort((a, b) => b.netAmount - a.netAmount), + premiumRequestBreakdown: [...premiumMap.entries()] + .map(([name, data]) => ({ name, ...data })) + .sort((a, b) => b.quantity - a.quantity), + repositoryBreakdown: [...repoMap.entries()] + .map(([name, data]) => ({ name, ...data })) + .sort((a, b) => b.netAmount - a.netAmount), + } +} + /* ── Main panel ── */ export default function CopilotMetricsPanel({ - orgData, + accountData, isLoading, - orgsLoading, - orgs, - selectedOrg, - onOrgChange, + targetsLoading, + targets, + selectedTarget, + onTargetChange, error, }: CopilotMetricsPanelProps) { const [activeSection, setActiveSection] = useState<'overview' | 'languages' | 'editors' | 'models'>('overview') - const currentOrgData = useMemo( - () => orgData.find((d) => d.org === selectedOrg), - [orgData, selectedOrg], + const agg = useMemo( + () => accountData?.type === 'organization' ? aggregateMetrics(accountData.metrics) : aggregateMetrics([]), + [accountData], ) - const agg = useMemo( - () => aggregateMetrics(currentOrgData?.metrics ?? []), - [currentOrgData], + const personalAgg = useMemo( + () => accountData?.type === 'personal' + ? aggregatePersonalUsage(accountData.usage, accountData.premiumRequests) + : aggregatePersonalUsage([], []), + [accountData], ) - const billing = currentOrgData?.billing ?? null + const billing = accountData?.type === 'organization' ? accountData.billing : null // Seat utilization as the top-level "usage" percentage const seatUtilisation = billing @@ -432,30 +504,28 @@ export default function CopilotMetricsPanel({ : 0 : null - if (orgsLoading) { + if (targetsLoading) { return (
-

Loading organisations…

+

Loading Copilot analytics sources…

) } - if (orgs.length === 0) { + if (targets.length === 0) { return (

Copilot Metrics

- Copilot usage metrics are available for organisations you belong to — not personal accounts. - GitHub does not provide a personal Copilot metrics API. Add repositories from an organisation - to get started, or ensure your token has the{' '} - manage_billing:copilot{' '} - or read:org scope and - you are an owner or billing manager of that organisation. + No Copilot analytics sources are available for this token yet. Personal analytics use your + account billing usage endpoints, while organization analytics require organization access and + a token with read:org{' '} + or manage_billing:copilot.

@@ -465,216 +535,312 @@ export default function CopilotMetricsPanel({ return (
{/* Org selector */} -
-
-
- -
-

Copilot Analytics

-

Usage metrics and insights for your organisation

+
+
+
+ +
+

Copilot Analytics

+

Organization metrics plus personal Copilot Pro usage

+
+
+
+ +
-
-
- - -
{/* Error banner */} - {(error || currentOrgData?.error) && ( + {(error || accountData?.error) && (

Some data could not be loaded

-

{currentOrgData?.error ?? error}

-

- Ensure your token has{' '} - manage_billing:copilot{' '} - or read:org{' '} - scope and you have org owner/billing manager access. -

+

{accountData?.error ?? error}

+ {accountData?.type === 'personal' ? ( +

+ Personal analytics use GitHub billing usage endpoints and require access to your own + billing data for the authenticated account. +

+ ) : ( +

+ Ensure your token has{' '} + manage_billing:copilot{' '} + or read:org{' '} + scope and you have org owner/billing manager access. +

+ )}
)} - {isLoading && !currentOrgData ? ( + {accountData?.type === 'personal' && ( +
+ GitHub does not expose the organization-style Copilot IDE metrics API for personal Copilot Pro + accounts. This personal view uses your billing usage and premium request analytics instead. +
+ )} + + {isLoading && !accountData ? (
Loading Copilot metrics…
) : ( <> - {/* Top-level usage bars */} -
- {seatUtilisation !== null && ( - - )} - {agg.totalDays > 0 && ( - - )} - {agg.totalSuggestions > 0 && ( - - )} -
+ {accountData?.type === 'organization' ? ( + <> + {/* Top-level usage bars */} +
+ {seatUtilisation !== null && ( + + )} + {agg.totalDays > 0 && ( + + )} + {agg.totalSuggestions > 0 && ( + + )} +
- {/* Summary cards */} -
- {billing && ( - } - label="Total seats" - value={billing.seat_breakdown.total.toString()} - sub={`${billing.seat_breakdown.active_this_cycle} active · ${billing.seat_breakdown.inactive_this_cycle} inactive · ${billing.plan_type}`} - /> - )} - } - label="Avg daily active users" - value={agg.avgActiveUsers.toString()} - sub={`${agg.avgEngagedUsers} avg engaged (${agg.totalDays} days)`} - /> - } - label="Code suggestions" - value={formatNumber(agg.totalSuggestions)} - sub={`${formatNumber(agg.totalAcceptances)} accepted (${agg.acceptanceRate.toFixed(1)}%)`} - /> - } - label="Total chats" - value={formatNumber(agg.totalChats + agg.totalDotcomChats)} - sub={`${formatNumber(agg.totalChatInsertions)} insertions · ${formatNumber(agg.totalChatCopies)} copies`} - /> - } - label="PR summaries" - value={formatNumber(agg.totalPRSummaries)} - sub="Auto-generated PR summaries" - /> - } - label="Lines accepted" - value={formatNumber(agg.totalLinesAccepted)} - sub={`of ${formatNumber(agg.totalLinesSuggested)} suggested (${agg.lineAcceptanceRate.toFixed(1)}%)`} - /> - {billing && ( - <> + {/* Summary cards */} +
+ {billing && ( + } + label="Total seats" + value={billing.seat_breakdown.total.toString()} + sub={`${billing.seat_breakdown.active_this_cycle} active · ${billing.seat_breakdown.inactive_this_cycle} inactive · ${billing.plan_type}`} + /> + )} } - label="New seats this cycle" - value={billing.seat_breakdown.added_this_cycle.toString()} - sub={`${billing.seat_breakdown.pending_invitation} pending invitation${billing.seat_breakdown.pending_invitation === 1 ? '' : 's'}`} + label="Avg daily active users" + value={agg.avgActiveUsers.toString()} + sub={`${agg.avgEngagedUsers} avg engaged (${agg.totalDays} days)`} /> } - label="Plan" - value={billing.plan_type.charAt(0).toUpperCase() + billing.plan_type.slice(1)} - sub={`Code suggestions: ${billing.public_code_suggestions}`} + icon={} + label="Code suggestions" + value={formatNumber(agg.totalSuggestions)} + sub={`${formatNumber(agg.totalAcceptances)} accepted (${agg.acceptanceRate.toFixed(1)}%)`} /> - - )} -
+ } + label="Total chats" + value={formatNumber(agg.totalChats + agg.totalDotcomChats)} + sub={`${formatNumber(agg.totalChatInsertions)} insertions · ${formatNumber(agg.totalChatCopies)} copies`} + /> + } + label="PR summaries" + value={formatNumber(agg.totalPRSummaries)} + sub="Auto-generated PR summaries" + /> + } + label="Lines accepted" + value={formatNumber(agg.totalLinesAccepted)} + sub={`of ${formatNumber(agg.totalLinesSuggested)} suggested (${agg.lineAcceptanceRate.toFixed(1)}%)`} + /> + {billing && ( + <> + } + label="New seats this cycle" + value={billing.seat_breakdown.added_this_cycle.toString()} + sub={`${billing.seat_breakdown.pending_invitation} pending invitation${billing.seat_breakdown.pending_invitation === 1 ? '' : 's'}`} + /> + } + label="Plan" + value={billing.plan_type.charAt(0).toUpperCase() + billing.plan_type.slice(1)} + sub={`Code suggestions: ${billing.public_code_suggestions}`} + /> + + )} +
- {/* Section tabs */} -
- {([ - { key: 'overview' as const, label: 'Overview', icon: }, - { key: 'languages' as const, label: 'Languages', icon: }, - { key: 'editors' as const, label: 'Editors', icon: }, - { key: 'models' as const, label: 'Models', icon: }, - ]).map(({ key, label, icon }) => ( - - ))} -
+ {/* Section tabs */} +
+ {([ + { key: 'overview' as const, label: 'Overview', icon: }, + { key: 'languages' as const, label: 'Languages', icon: }, + { key: 'editors' as const, label: 'Editors', icon: }, + { key: 'models' as const, label: 'Models', icon: }, + ]).map(({ key, label, icon }) => ( + + ))} +
- {/* Section content */} - {activeSection === 'overview' && ( - - )} + {/* Section content */} + {activeSection === 'overview' && ( + + )} + + {activeSection === 'languages' && ( + } + columns={[ + { header: 'Language', accessor: (r) => r.name }, + { header: 'Suggestions', accessor: (r) => formatNumber(r.suggestions), align: 'right' }, + { header: 'Accepted', accessor: (r) => formatNumber(r.acceptances), align: 'right' }, + { header: 'Accept rate', accessor: (r) => `${r.rate.toFixed(1)}%`, align: 'right' }, + ]} + rows={agg.languageBreakdown} + getKey={(r) => r.name} + /> + )} + + {activeSection === 'editors' && ( + } + columns={[ + { header: 'Editor', accessor: (r) => r.name }, + { header: 'Max users', accessor: (r) => r.users.toString(), align: 'right' }, + { header: 'Suggestions', accessor: (r) => formatNumber(r.suggestions), align: 'right' }, + { header: 'Accepted', accessor: (r) => formatNumber(r.acceptances), align: 'right' }, + ]} + rows={agg.editorBreakdown} + getKey={(r) => r.name} + /> + )} + + {activeSection === 'models' && ( + } + columns={[ + { header: 'Model', accessor: (r) => `${r.name}${r.isCustom ? ' (custom)' : ''}` }, + { header: 'Suggestions', accessor: (r) => formatNumber(r.suggestions), align: 'right' }, + { header: 'Accepted', accessor: (r) => formatNumber(r.acceptances), align: 'right' }, + { header: 'Chats', accessor: (r) => formatNumber(r.chats), align: 'right' }, + { header: 'PR summaries', accessor: (r) => formatNumber(r.prSummaries), align: 'right' }, + ]} + rows={agg.modelBreakdown} + getKey={(r) => r.name} + /> + )} - {activeSection === 'languages' && ( - } - columns={[ - { header: 'Language', accessor: (r) => r.name }, - { header: 'Suggestions', accessor: (r) => formatNumber(r.suggestions), align: 'right' }, - { header: 'Accepted', accessor: (r) => formatNumber(r.acceptances), align: 'right' }, - { header: 'Accept rate', accessor: (r) => `${r.rate.toFixed(1)}%`, align: 'right' }, - ]} - rows={agg.languageBreakdown} - getKey={(r) => r.name} - /> - )} + {agg.totalDays === 0 && !isLoading && ( +
+ No Copilot metrics data available for {accountData.org}. + Metrics may take up to 24 hours to appear after Copilot activity. +
+ )} + + ) : ( + <> +
+ } + label="Billable units" + value={formatNumber(personalAgg.totalUsageQuantity)} + sub="From personal billing usage" + /> + } + label="Premium requests" + value={formatNumber(personalAgg.totalPremiumRequests)} + sub="Metered Copilot premium requests" + /> + } + label="Net spend" + value={`$${personalAgg.totalNetAmount.toFixed(2)}`} + sub="Across Copilot-billed usage items" + /> + } + label="Products / SKUs" + value={personalAgg.distinctProducts.toString()} + sub="Distinct Copilot billing lines" + /> +
- {activeSection === 'editors' && ( - } - columns={[ - { header: 'Editor', accessor: (r) => r.name }, - { header: 'Max users', accessor: (r) => r.users.toString(), align: 'right' }, - { header: 'Suggestions', accessor: (r) => formatNumber(r.suggestions), align: 'right' }, - { header: 'Accepted', accessor: (r) => formatNumber(r.acceptances), align: 'right' }, - ]} - rows={agg.editorBreakdown} - getKey={(r) => r.name} - /> - )} + } + columns={[ + { header: 'Product / SKU', accessor: (r) => r.name }, + { header: 'Quantity', accessor: (r) => formatNumber(r.quantity), align: 'right' }, + { header: 'Net amount', accessor: (r) => `$${r.netAmount.toFixed(2)}`, align: 'right' }, + { header: 'Unit', accessor: (r) => r.unitType, align: 'right' }, + ]} + rows={personalAgg.productBreakdown} + getKey={(r) => r.name} + /> - {activeSection === 'models' && ( - } - columns={[ - { header: 'Model', accessor: (r) => `${r.name}${r.isCustom ? ' (custom)' : ''}` }, - { header: 'Suggestions', accessor: (r) => formatNumber(r.suggestions), align: 'right' }, - { header: 'Accepted', accessor: (r) => formatNumber(r.acceptances), align: 'right' }, - { header: 'Chats', accessor: (r) => formatNumber(r.chats), align: 'right' }, - { header: 'PR summaries', accessor: (r) => formatNumber(r.prSummaries), align: 'right' }, - ]} - rows={agg.modelBreakdown} - getKey={(r) => r.name} - /> - )} + } + columns={[ + { header: 'Product / model', accessor: (r) => r.name }, + { header: 'Requests', accessor: (r) => formatNumber(r.quantity), align: 'right' }, + { header: 'Net amount', accessor: (r) => `$${r.netAmount.toFixed(2)}`, align: 'right' }, + ]} + rows={personalAgg.premiumRequestBreakdown} + getKey={(r) => r.name} + /> - {agg.totalDays === 0 && !isLoading && ( -
- No Copilot metrics data available for {selectedOrg}. - Metrics may take up to 24 hours to appear after Copilot activity. -
+ {personalAgg.repositoryBreakdown.length > 0 && ( + } + columns={[ + { header: 'Repository', accessor: (r) => r.name }, + { header: 'Quantity', accessor: (r) => formatNumber(r.quantity), align: 'right' }, + { header: 'Net amount', accessor: (r) => `$${r.netAmount.toFixed(2)}`, align: 'right' }, + ]} + rows={personalAgg.repositoryBreakdown} + getKey={(r) => r.name} + /> + )} + + {personalAgg.productBreakdown.length === 0 && personalAgg.premiumRequestBreakdown.length === 0 && !isLoading && ( +
+ No personal Copilot billing usage was returned for {accountData.account}. + GitHub usually shows this data in your billing settings after usage has been processed. +
+ )} + )} )} diff --git a/src/components/TokenSettings.tsx b/src/components/TokenSettings.tsx index 4088ff3..8103764 100644 --- a/src/components/TokenSettings.tsx +++ b/src/components/TokenSettings.tsx @@ -68,7 +68,10 @@ function TokenSettingsForm({

Required for fetching live PR data. Recommend a fine-grained PAT with - read-only access to pull requests and checks. Token is stored in your browser's localStorage. + read-only access to pull requests and checks. For the Copilot tab, personal analytics use your + account billing usage access and organization analytics may require a classic PAT with{' '} + read:org or manage_billing:copilot. Token is stored in your + browser's localStorage.

{ expect(fetchSpy).toHaveBeenCalledTimes(2) }) }) + +describe('fetchCopilotPersonalData', () => { + it('loads personal billing usage and premium request usage from user billing endpoints', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(okResponse({ + usageItems: [ + { + product: 'GitHub Copilot', + sku: 'copilot_pro', + unitType: 'requests', + pricePerUnit: 0, + grossAmount: 0, + discountAmount: 0, + netAmount: 0, + quantity: 42, + }, + ], + })) + .mockResolvedValueOnce(okResponse({ + timePeriod: { year: 2026, month: 4 }, + user: 'octocat', + usageItems: [ + { + product: 'GitHub Copilot', + sku: 'premium_requests', + model: 'gpt-5', + unitType: 'requests', + pricePerUnit: 0.04, + grossAmount: 2, + discountAmount: 0, + netAmount: 2, + netQuantity: 50, + }, + ], + })) + + const result = await fetchCopilotPersonalData('octocat', TOKEN) + + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('https://api.github.com/users/octocat/settings/billing/usage') + expect(fetchSpy.mock.calls[1]?.[0]).toBe('https://api.github.com/users/octocat/settings/billing/premium_request/usage') + expect(result).toEqual({ + type: 'personal', + target: 'personal:octocat', + account: 'octocat', + usage: [ + expect.objectContaining({ sku: 'copilot_pro', quantity: 42 }), + ], + premiumRequests: [ + expect.objectContaining({ sku: 'premium_requests', netQuantity: 50 }), + ], + error: undefined, + }) + }) + + it('keeps partial results and surfaces partial failures', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(errorResponse(403, { message: 'denied' })) + .mockResolvedValueOnce(okResponse({ + timePeriod: { year: 2026, month: 4 }, + user: 'octocat', + usageItems: [], + })) + + const result = await fetchCopilotPersonalData('octocat', TOKEN) + + expect(result.usage).toEqual([]) + expect(result.premiumRequests).toEqual([]) + expect(result.error).toContain('Usage:') + }) +}) diff --git a/src/github-api.ts b/src/github-api.ts index 5766ea2..dd45909 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -1,4 +1,19 @@ -import type { BranchAnalysis, BranchStatus, ChecksStatus, CopilotBilling, CopilotDayMetrics, CopilotOrgData, CopilotReview, MergeSafety, PRComment, PullRequest } from './types' +import type { + BranchAnalysis, + BranchStatus, + ChecksStatus, + CopilotBilling, + CopilotBillingUsageItem, + CopilotBillingUsageReport, + CopilotDayMetrics, + CopilotOrgData, + CopilotPersonalData, + CopilotPremiumRequestUsageReport, + CopilotReview, + MergeSafety, + PRComment, + PullRequest, +} from './types' export type GitHubRepo = { full_name: string @@ -663,9 +678,82 @@ export async function fetchCopilotOrgData( } return { + type: 'organization', + target: `org:${org}`, org, billing, metrics, error: errors.length > 0 ? errors.join('; ') : undefined, } } + +function isCopilotBillingItem(item: Pick): boolean { + return /copilot/i.test(item.product ?? '') || /copilot/i.test(item.sku ?? '') +} + +function filterCopilotBillingItems(items: CopilotBillingUsageItem[]): CopilotBillingUsageItem[] { + const copilotItems = items.filter(isCopilotBillingItem) + return copilotItems.length > 0 ? copilotItems : items +} + +/** + * Fetch billing usage for a personal account. This includes Copilot usage billed + * directly to the authenticated user. + */ +export async function fetchUserBillingUsage( + username: string, + token: string, +): Promise { + const report = await githubFetch( + `/users/${encodeURIComponent(username)}/settings/billing/usage`, + token, + ) + return filterCopilotBillingItems(report.usageItems ?? []) +} + +/** + * Fetch premium request usage for a personal account. + */ +export async function fetchUserPremiumRequestUsage( + username: string, + token: string, +): Promise { + const report = await githubFetch( + `/users/${encodeURIComponent(username)}/settings/billing/premium_request/usage`, + token, + ) + return filterCopilotBillingItems(report.usageItems ?? []) +} + +/** + * Fetch personal Copilot analytics using GitHub billing usage APIs. + */ +export async function fetchCopilotPersonalData( + username: string, + token: string, +): Promise { + const [usageResult, premiumRequestsResult] = await Promise.allSettled([ + fetchUserBillingUsage(username, token), + fetchUserPremiumRequestUsage(username, token), + ]) + + const usage = usageResult.status === 'fulfilled' ? usageResult.value : [] + const premiumRequests = premiumRequestsResult.status === 'fulfilled' ? premiumRequestsResult.value : [] + + const errors: string[] = [] + if (usageResult.status === 'rejected') { + errors.push(`Usage: ${usageResult.reason instanceof Error ? usageResult.reason.message : 'Unknown error'}`) + } + if (premiumRequestsResult.status === 'rejected') { + errors.push(`Premium requests: ${premiumRequestsResult.reason instanceof Error ? premiumRequestsResult.reason.message : 'Unknown error'}`) + } + + return { + type: 'personal', + target: `personal:${username}`, + account: username, + usage, + premiumRequests, + error: errors.length > 0 ? errors.join('; ') : undefined, + } +} diff --git a/src/types.ts b/src/types.ts index 7e55f8c..eea774c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -134,8 +134,59 @@ export type CopilotBilling = { /* ── Aggregated Copilot metrics for display ── */ export type CopilotOrgData = { + type: 'organization' + target: string org: string billing: CopilotBilling | null metrics: CopilotDayMetrics[] error?: string } + +export type CopilotBillingUsageItem = { + product: string + sku: string + unitType: string + pricePerUnit: number + grossAmount: number + discountAmount: number + netAmount: number + quantity?: number + grossQuantity?: number + discountQuantity?: number + netQuantity?: number + repositoryName?: string + model?: string +} + +export type CopilotBillingUsageReport = { + usageItems: CopilotBillingUsageItem[] +} + +export type CopilotPremiumRequestUsageReport = { + timePeriod: { + year: number + month?: number + day?: number + } + user: string + product?: string + model?: string + usageItems: CopilotBillingUsageItem[] +} + +export type CopilotPersonalData = { + type: 'personal' + target: string + account: string + usage: CopilotBillingUsageItem[] + premiumRequests: CopilotBillingUsageItem[] + error?: string +} + +export type CopilotAccountData = CopilotOrgData | CopilotPersonalData + +export type CopilotAnalyticsTarget = { + id: string + label: string + type: 'organization' | 'personal' +} From 4e07ab7e67801178f5790f023758ada0030f4c08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:22:00 +0000 Subject: [PATCH 2/3] docs: clarify copilot analytics permissions Agent-Logs-Url: https://github.com/thinkdj/gh.devcp/sessions/c04af5d6-e865-425a-bcac-b0193097cebb Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- src/App.test.tsx | 3 ++- src/App.tsx | 4 ++-- src/components/CopilotMetricsPanel.tsx | 2 +- src/demo-data.ts | 2 ++ src/github-api.ts | 4 +++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 74a34c8..1fc93ec 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1063,7 +1063,8 @@ describe('PR Pulse – The GitHub Control Panel', () => { await waitFor(() => { expect(mockCopilotPersonalData).toHaveBeenCalledWith('test-user', expect.any(String)) }) - expect(screen.getByText('12')).toBeInTheDocument() + expect(screen.getByText(/billable units/i)).toBeInTheDocument() + expect(screen.getByText(/from personal billing usage/i)).toBeInTheDocument() }) it('shows usage bars and summary cards when metrics data is available', async () => { diff --git a/src/App.tsx b/src/App.tsx index c60812b..fb4ed31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { Bot, FlaskConical, } from 'lucide-react' -import type { CopilotAnalyticsTarget, DashboardFilter, PullRequest } from './types' +import type { CopilotAccountData, CopilotAnalyticsTarget, DashboardFilter, PullRequest } from './types' import { loadRepoState, loadToken, loadTheme, loadMode, saveRepoState, saveToken, saveTheme, saveMode } from './storage' import type { Theme, Mode } from './storage' import { clearApiCache, fetchCopilotOrgData, fetchCopilotPersonalData, fetchCurrentUser, fetchRepoBranches, fetchRepoPullRequests, fetchUserOrgs, fetchUserRepos } from './github-api' @@ -217,7 +217,7 @@ function App() { ?? copilotTargets[0] ?? null - const copilotQuery = useQuery({ + const copilotQuery = useQuery({ queryKey: ['copilot-account', token, selectedCopilotTarget?.id], queryFn: isDemoMode ? () => { diff --git a/src/components/CopilotMetricsPanel.tsx b/src/components/CopilotMetricsPanel.tsx index 76b7f6d..c3a6449 100644 --- a/src/components/CopilotMetricsPanel.tsx +++ b/src/components/CopilotMetricsPanel.tsx @@ -836,7 +836,7 @@ export default function CopilotMetricsPanel({ className="rounded-lg border border-gh-border bg-gh-surface px-6 py-10 text-center text-sm text-gh-fg-muted" data-testid="copilot-no-metrics" > - No personal Copilot billing usage was returned for {accountData.account}. + No personal Copilot billing usage was returned for {accountData?.account ?? 'this account'}. GitHub usually shows this data in your billing settings after usage has been processed.
)} diff --git a/src/demo-data.ts b/src/demo-data.ts index bfe8389..51f684b 100644 --- a/src/demo-data.ts +++ b/src/demo-data.ts @@ -763,6 +763,8 @@ export const demoCopilotMetrics = DEMO_METRICS_BASE.map(([date, active, engaged, export const demoCopilotOrgData: CopilotOrgData[] = [ { + type: 'organization', + target: 'org:acme-corp', org: 'acme-corp', billing: { seat_breakdown: { diff --git a/src/github-api.ts b/src/github-api.ts index dd45909..e2b32c7 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -687,8 +687,10 @@ export async function fetchCopilotOrgData( } } +const COPILOT_BILLING_PATTERN = /(?:^|[\s._-])copilot(?:$|[\s._-])/i + function isCopilotBillingItem(item: Pick): boolean { - return /copilot/i.test(item.product ?? '') || /copilot/i.test(item.sku ?? '') + return COPILOT_BILLING_PATTERN.test(item.product ?? '') || COPILOT_BILLING_PATTERN.test(item.sku ?? '') } function filterCopilotBillingItems(items: CopilotBillingUsageItem[]): CopilotBillingUsageItem[] { From 2f7db67284e61c3ceb9882cef0a9105135102d60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:13:55 +0000 Subject: [PATCH 3/3] fix: address peer review feedback on Copilot personal analytics Agent-Logs-Url: https://github.com/thinkdj/gh.devcp/sessions/d26275e0-0171-48af-babd-ea2cc66393a7 Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- src/components/CopilotMetricsPanel.tsx | 5 +++-- src/github-api.ts | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/CopilotMetricsPanel.tsx b/src/components/CopilotMetricsPanel.tsx index c3a6449..7233dd5 100644 --- a/src/components/CopilotMetricsPanel.tsx +++ b/src/components/CopilotMetricsPanel.tsx @@ -355,7 +355,7 @@ function BreakdownTable>({ getKey, }: { title: string - icon: React.ReactNode + icon: ReactNode columns: { header: string; accessor: (row: T) => string; align?: 'right' }[] rows: T[] getKey: (row: T) => string @@ -446,6 +446,7 @@ function aggregatePersonalUsage( for (const item of premiumRequests) { const quantity = item.netQuantity ?? item.quantity ?? item.grossQuantity ?? 0 totalPremiumRequests += quantity + totalNetAmount += item.netAmount ?? 0 const key = item.model ? `${item.product} · ${item.model}` : `${item.product} · ${item.sku}` const existing = premiumMap.get(key) ?? { quantity: 0, netAmount: 0 } existing.quantity += quantity @@ -534,7 +535,7 @@ export default function CopilotMetricsPanel({ return (
- {/* Org selector */} + {/* Account/target selector */}
diff --git a/src/github-api.ts b/src/github-api.ts index e2b32c7..f58b991 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -694,8 +694,7 @@ function isCopilotBillingItem(item: Pick 0 ? copilotItems : items + return items.filter(isCopilotBillingItem) } /**