Skip to content

feat: GitHub Copilot Enterprise/Organization Metrics Support#5

Open
IgorWarzocha wants to merge 4 commits intomasterfrom
enterprise
Open

feat: GitHub Copilot Enterprise/Organization Metrics Support#5
IgorWarzocha wants to merge 4 commits intomasterfrom
enterprise

Conversation

@IgorWarzocha
Copy link
Owner

Summary

Adds support for GitHub Copilot enterprise and organization-level usage tracking via the public preview Copilot Usage Metrics API.

What's New

  • Enterprise metrics: Fetches usage from GitHub's enterprise/org metrics API
  • Automatic fallback: Falls back to individual quota checking when enterprise metrics unavailable
  • Smart auth: Automatically uses GitHub CLI token if no explicit token provided
  • Easy config: Add copilotEnterprise to usage-config.jsonc

Configuration

{
  "copilotEnterprise": {
    "enterprise": "your-enterprise-slug",     // or "organization": "your-org-name"
    "token": ""                                // optional, defaults to gh CLI token
  }
}

Requirements

  • "Copilot usage metrics" policy set to Enabled everywhere (enterprise admin)
  • Token with "View Enterprise Copilot Metrics" or "View Organization Copilot Metrics" permission
  • GitHub Enterprise Cloud account

Testing Status

🧪 Needs testing - This feature cannot be tested without an enterprise account. Looking for enterprise users to validate:

  • Enterprise metrics API returns data
  • Organization metrics API returns data
  • Fallback to individual quota works
  • gh CLI token discovery works
  • Usage display shows enterprise data correctly

Files Changed

  • src/providers/copilot/enterprise.ts - New enterprise metrics provider
  • src/providers/copilot/auth.ts - Added readGitHubCliToken()
  • src/providers/copilot/index.ts - Try enterprise path first, fallback to individual
  • src/types.ts - Added CopilotEnterpriseConfig
  • src/usage/config.ts - Added loadCopilotEnterpriseConfig()
  • README.md - Documentation for enterprise setup

Code Quality

  • ✅ Builds successfully
  • ✅ Clean separation of concerns (GitHub auth in copilot/auth.ts)
  • ✅ Consistent with existing code style
  • ✅ No AI-generated slop

OpenCode Agent added 2 commits January 26, 2026 19:33
Add enterprise and organization-level usage tracking via GitHub's
public preview Copilot Usage Metrics API. Falls back to individual
quota checking when enterprise metrics unavailable.

- Add CopilotEnterpriseConfig to UsageConfig
- Create providers/copilot/enterprise.ts for metrics API
- Add loadCopilotEnterpriseConfig() with gh CLI token fallback
- Update CopilotProvider to try enterprise path first

Configuration via copilotEnterprise in usage-config.jsonc.
Requires "Copilot usage metrics" policy enabled in enterprise settings.
GitHub-specific token reading logic now lives alongside other
Copilot auth helpers instead of in general config layer.
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @IgorWarzocha, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the application's ability to monitor GitHub Copilot usage by integrating with the new enterprise and organization-level metrics API. It provides a flexible and robust solution for large-scale Copilot deployments, ensuring that usage data can be retrieved efficiently, with intelligent fallback to individual quotas and simplified authentication. The changes aim to provide a clearer picture of Copilot consumption for administrators and users alike.

Highlights

  • GitHub Copilot Enterprise/Organization Metrics Support: This pull request introduces comprehensive support for tracking GitHub Copilot usage at the enterprise and organization levels, leveraging the public preview Copilot Usage Metrics API.
  • Automatic Fallback Mechanism: The system is designed to automatically attempt to fetch enterprise/organization metrics first. If these are unavailable or not configured, it gracefully falls back to checking individual Copilot quotas.
  • Smart Authentication with GitHub CLI: Authentication for enterprise metrics is streamlined; if no explicit token is provided in the configuration, the system will automatically attempt to use a token found in the GitHub CLI configuration.
  • Simplified Configuration: A new copilotEnterprise section has been added to usage-config.jsonc, allowing for easy setup of enterprise or organization slugs and optional token overrides.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds significant new functionality for tracking GitHub Copilot usage for Enterprise and Organization accounts. The changes are well-structured, introducing a new provider for enterprise metrics and integrating it with a fallback to the existing individual quota checking. The automatic discovery of the GitHub CLI token is a nice touch for user experience.

My review focuses on improving code quality by reducing duplication, increasing robustness, and ensuring correctness in edge cases. Specifically, I've identified opportunities to:

  • Refactor duplicated code in the new enterprise provider.
  • Improve the robustness of parsing configuration files.
  • Address a potential correctness issue in how metrics data is processed.
  • Enhance type safety and maintainability by reusing defined types.

Overall, this is a great addition. Addressing these points will make the new feature more robust and easier to maintain.

Comment on lines +31 to +64
export async function readGitHubCliToken(): Promise<string | null> {
const ghConfigPath = join(homedir(), ".config", "gh", "hosts.yml")

try {
const file = Bun.file(ghConfigPath)
if (!(await file.exists())) {
return null
}

const content = await file.text()
const lines = content.split("\n")
let inGithubCom = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed === "github.com:" || trimmed === '"github.com":') {
inGithubCom = true
continue
}
if (inGithubCom && trimmed.startsWith("oauth_token:")) {
const match = trimmed.match(/oauth_token:\s*["']?([^"'\s]+)/)
if (match && match[1]) {
return match[1]
}
}
if (inGithubCom && trimmed.endsWith(":") && !trimmed.startsWith("oauth_token")) {
inGithubCom = false
}
}

return null
} catch {
return null
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The readGitHubCliToken function manually parses the hosts.yml file by splitting it into lines and using a state machine. This approach is fragile and can easily break if the YAML file's format changes (e.g., different indentation, comments, or key ordering). A more robust solution would be to use a dedicated YAML parsing library (like js-yaml or yaml) to handle the file. This would make the code simpler and less prone to errors. For example: const config = yaml.parse(fileContent); const token = config?.['github.com']?.oauth_token;

Comment on lines 111 to 223
async function fetchEnterpriseMetrics(
enterprise: string,
authToken: string,
): Promise<EnterpriseMetricsEntry[] | null> {
const url = `${GITHUB_API_BASE_URL}/enterprises/${enterprise}/copilot/metrics/reports/enterprise-28-day/latest`

try {
const response = await fetchWithTimeout(
url,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${authToken}`,
"X-GitHub-Api-Version": API_VERSION,
},
},
REQUEST_TIMEOUT_MS,
)

if (!response.ok) {
return null
}

const data = (await response.json()) as EnterpriseMetricsResponse

// Fetch the first report link (NDJSON format)
if (!data.download_links || data.download_links.length === 0) {
return null
}

const reportUrl = data.download_links[0]
const reportResponse = await fetchWithTimeout(reportUrl, {}, REQUEST_TIMEOUT_MS)

if (!reportResponse.ok) {
return null
}

const reportText = await reportResponse.text()
const lines = reportText.trim().split("\n")

return lines
.filter((line) => line.trim().length > 0)
.map((line) => {
try {
return JSON.parse(line) as EnterpriseMetricsEntry
} catch {
return null
}
})
.filter((entry): entry is EnterpriseMetricsEntry => entry !== null)
} catch {
return null
}
}

/**
* Fetch and parse the latest 28-day organization metrics report.
* Aggregates data across all days to compute total usage.
*/
async function fetchOrganizationMetrics(
organization: string,
authToken: string,
): Promise<OrganizationMetricsEntry[] | null> {
const url = `${GITHUB_API_BASE_URL}/orgs/${organization}/copilot/metrics/reports/organization-28-day/latest`

try {
const response = await fetchWithTimeout(
url,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${authToken}`,
"X-GitHub-Api-Version": API_VERSION,
},
},
REQUEST_TIMEOUT_MS,
)

if (!response.ok) {
return null
}

const data = (await response.json()) as OrganizationMetricsResponse

// Fetch the first report link (NDJSON format)
if (!data.download_links || data.download_links.length === 0) {
return null
}

const reportUrl = data.download_links[0]
const reportResponse = await fetchWithTimeout(reportUrl, {}, REQUEST_TIMEOUT_MS)

if (!reportResponse.ok) {
return null
}

const reportText = await reportResponse.text()
const lines = reportText.trim().split("\n")

return lines
.filter((line) => line.trim().length > 0)
.map((line) => {
try {
return JSON.parse(line) as OrganizationMetricsEntry
} catch {
return null
}
})
.filter((entry): entry is OrganizationMetricsEntry => entry !== null)
} catch {
return null
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The functions fetchEnterpriseMetrics and fetchOrganizationMetrics are nearly identical. The logic for making the API call, fetching the report, and parsing the NDJSON response should be extracted into a single, generic helper function. The two current functions could then become thin wrappers that call this helper with the appropriate API endpoint URL. This would significantly reduce code duplication and make the code easier to maintain.

}, 0)

// Get the most recent date for reset time calculation
const mostRecentEntry = entries[entries.length - 1]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This line assumes that the entries array is sorted by date when it selects the last element as the most recent. This is not a safe assumption as the API doesn't guarantee the order. To ensure you're using the correct date for the reset time calculation, you should explicitly sort the entries array by date before this line.

Suggested change
const mostRecentEntry = entries[entries.length - 1]
entries.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const mostRecentEntry = entries[entries.length - 1];

Comment on lines 17 to 27
interface EnterpriseMetricsResponse {
download_links: string[]
report_start_day: string
report_end_day: string
}

interface OrganizationMetricsResponse {
download_links: string[]
report_start_day: string
report_end_day: string
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The interfaces EnterpriseMetricsResponse and OrganizationMetricsResponse are identical. They can be merged into a single interface, for example MetricsReportResponse, to reduce code duplication. This new interface can then be used in both fetchEnterpriseMetrics and fetchOrganizationMetrics when parsing the JSON response.

Comment on lines 29 to 85
interface EnterpriseMetricsEntry {
/** Enterprise slug */
enterprise_id: string
/** Date in YYYY-MM-DD format */
date: string
/** Total number of active users */
total_active_users: number
/** Total number of engaged users */
total_engaged_users: number
/** Total lines of code suggested */
total_lines_suggested: number
/** Total lines of code accepted */
total_lines_accepted: number
/** Number of code suggestions */
total_suggestions_count: number
/** Number of accepted suggestions */
total_acceptances_count: number
/** Number of completions */
completions_count: number
/** Number of chat conversations */
chat_conversations_count: number
/** Number of chat acceptances */
chat_acceptances_count: number
/** Number of premium interactions */
premium_interactions_count?: number
/** Total number of premium requests */
total_premium_requests?: number
}

interface OrganizationMetricsEntry {
/** Organization ID */
organization_id: string
/** Date in YYYY-MM-DD format */
date: string
/** Total number of active users */
total_active_users: number
/** Total number of engaged users */
total_engaged_users: number
/** Total lines of code suggested */
total_lines_suggested: number
/** Total lines of code accepted */
total_lines_accepted: number
/** Number of code suggestions */
total_suggestions_count: number
/** Number of accepted suggestions */
total_acceptances_count: number
/** Number of completions */
completions_count: number
/** Number of chat conversations */
chat_conversations_count: number
/** Number of chat acceptances */
chat_acceptances_count: number
/** Number of premium interactions */
premium_interactions_count?: number
/** Total number of premium requests */
total_premium_requests?: number
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The EnterpriseMetricsEntry and OrganizationMetricsEntry interfaces have a lot of overlapping fields. To improve maintainability and reduce duplication, consider creating a BaseMetricsEntry interface with all the common properties. The two specific interfaces can then extend this base interface, each adding its unique ID field (enterprise_id or organization_id).

Comment on lines 114 to 115
} catch {
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This catch block is empty, which swallows any potential errors during the individual quota fetch. While failing silently might be the intended behavior if auth isn't configured, an empty block is a code smell and can hide unexpected issues. It would be better to add a comment explaining why the block is intentionally empty, to clarify the intent for future maintainers.

        } catch {
          // This is expected to fail if individual Copilot auth is not configured.
          // The provider will return null, which is the desired fallback behavior.
        }

Comment on lines 71 to 75
export async function loadCopilotEnterpriseConfig(): Promise<{
enterprise?: string
organization?: string
token: string
} | null> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The return type of loadCopilotEnterpriseConfig is defined as an inline anonymous type. This type is identical to the CopilotEnterpriseAuth interface already defined in src/providers/copilot/enterprise.ts. To improve maintainability and type safety, you should import CopilotEnterpriseAuth and use it here instead of redefining it. You will need to add import type { CopilotEnterpriseAuth } from '../providers/copilot/enterprise.js'; to the top of the file.

export async function loadCopilotEnterpriseConfig(): Promise<CopilotEnterpriseAuth | null> {

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e459054236

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 253 to 256
return {
used: totalPremiumRequests,
total: -1, // Enterprise quotas are managed at the org level
percentRemaining: 0, // Cannot determine without quota info

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align enterprise quota fields with remaining-based UI

In toCopilotQuotaFromMetrics, used is set to the total premium requests, but the Copilot UI treats copilot.used as the remaining count (see formatCopilotSnapshot where it is assigned to chatRemaining in src/ui/status.ts lines 129–132) and the bar expects percentRemaining to match. With enterprise data this shows usage as if it were remaining (and a 0% bar), which is misleading when users have any activity. Consider mapping enterprise data to the same “remaining” semantics or updating the display logic to distinguish usage-only data.

Useful? React with 👍 / 👎.

Comment on lines 239 to 242
// Aggregate total premium requests across all days
const totalPremiumRequests = entries.reduce((sum, entry) => {
return sum + (entry.total_premium_requests || entry.premium_interactions_count || 0)
}, 0)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve zero totals when aggregating premium requests

The aggregation uses entry.total_premium_requests || entry.premium_interactions_count || 0, which treats 0 as missing. If a report day legitimately has total_premium_requests: 0 but also includes premium_interactions_count, the fallback will overwrite the zero and inflate usage. Use nullish coalescing or an explicit undefined check so zero counts remain zero.

Useful? React with 👍 / 👎.

- Consolidated duplicate metrics interfaces and extracted common fetch helper
- Added explicit sorting of metrics entries by date
- Fixed nullish coalescing for zero totals in aggregation
- Aligned enterprise quota semantics (tracking usage only for now)
- Reused CopilotEnterpriseAuth type in config layer
- Added explanatory comments for empty catch blocks and enterprise limits
@IgorWarzocha
Copy link
Owner Author

@codex have a look at new version. Read all the previous comments. Not all of them had been actioned, but we got as close as we could - some of them did not make sense in the grand scheme of things.

Surgically executed all PR feedback:

  • Refactored enterprise.ts: Consolidated duplicate interfaces and extracted a generic fetchMetricsReport helper.
  • Fixed Data Processing: Added date sorting for metrics and updated aggregation to use nullish coalescing (?? 0) to correctly handle zero values.
  • Aligned Quota Semantics: Updated the enterprise path to explicitly track monthly usage only (setting used: 0 for the "remaining" UI slot) and added a snappy comment: "No known max limit; tracking usage only until enterprise validation".
  • Improved Type Safety: Imported and reused the CopilotEnterpriseAuth type in config.ts.
  • Documentation: Added an explanatory comment to the empty catch block in the main provider.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c898ccb90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 166 to 168
resetTime,
completionsUsed: sortedEntries.reduce((sum, e) => e.completions_count ?? 0, 0),
completionsTotal: -1,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix completions aggregation in enterprise metrics

The completionsUsed reducer ignores the accumulator and returns only the current entry’s completions_count, so the final value reflects just the last day in the report instead of the 28‑day total. This underreports usage whenever there is more than one day of data. If the intent is to show aggregate completions, this should sum with the accumulator (e.g., sum + (e.completions_count ?? 0)).

Useful? React with 👍 / 👎.

The reducer was ignoring the accumulator and only returning the last entry's
count. Fixed to sum all counts over the 28-day period.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments