Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
65 changes: 49 additions & 16 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: [],
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -1029,11 +1050,21 @@ 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(/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 () => {
Expand All @@ -1042,6 +1073,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 },
Expand Down
88 changes: 52 additions & 36 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import {
Bot,
FlaskConical,
} from 'lucide-react'
import type { 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, 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'
Expand Down Expand Up @@ -62,7 +62,7 @@ function App() {
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false)
const [branchAnalysisTriggered, setBranchAnalysisTriggered] = useState(false)
const [branchAnalysisRepos, setBranchAnalysisRepos] = useState<string[]>([])
const [selectedCopilotOrgRaw, setSelectedCopilotOrg] = useState('')
const [selectedCopilotTargetRaw, setSelectedCopilotTarget] = useState('')
useEffect(() => {
applyTheme(theme, mode)
}, [theme, mode])
Expand Down Expand Up @@ -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<CopilotAnalyticsTarget[]>(
() => [
...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<CopilotAccountData | null>({
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
Expand Down Expand Up @@ -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'] })
}

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

Expand Down Expand Up @@ -597,12 +613,12 @@ function App() {
/>
) : activeView === 'copilot' ? (
<CopilotMetricsPanel
orgData={copilotOrgData}
accountData={copilotData}
isLoading={copilotIsLoading}
orgsLoading={orgsIsPending}
orgs={copilotOrgs}
selectedOrg={selectedCopilotOrg}
onOrgChange={setSelectedCopilotOrg}
targetsLoading={copilotTargetsLoading}
targets={copilotTargets}
selectedTarget={selectedCopilotTarget?.id ?? ''}
onTargetChange={setSelectedCopilotTarget}
error={copilotError ? (copilotError instanceof Error ? copilotError.message : 'Failed to fetch Copilot data') : null}
/>
) : (
Expand Down
Loading