Repository navigation
Expand file tree
/
Copy pathgithub-copilot-report.sh
More file actions
executable file
·634 lines (570 loc) · 31.8 KB
/
Copy pathgithub-copilot-report.sh
File metadata and controls
executable file
·634 lines (570 loc) · 31.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
#!/usr/bin/env bash
# =============================================================================
# github-copilot-report.sh
#
# GitHub Copilot Enterprise licence & usage report, optionally enriched with
# Entra ID department data. Output: CSV file + console summary.
#
# Authentication — no secrets required in flags:
# GitHub : GITHUB_TOKEN env var (PAT with read:enterprise and
# manage_billing:enterprise scopes)
# OR provided automatically from an active gh auth session
# with the required scopes
# Entra : az CLI (optional; run 'az login' once; needs User.Read.All)
# Skipped automatically if az is not installed or not logged in.
# Use --no-entra to suppress Entra lookups explicitly.
#
# What this reports:
# • Every user with a Copilot seat, their plan type, and their pool contribution
# (credits per assigned seat — credits are pooled at the enterprise level)
# • Actual per-user AI credit consumption this month (from billing API)
# • Each user's effective AI credit budget (Individual override, else Universal)
# and how much of it has been consumed (from the Budgets API)
# • Users grouped by Entra ID department
# • Enterprise-level model usage breakdown
#
# AI credit pool — credits per assigned seat (GitHub usage-based billing, 2026):
# Copilot Business : 1,900 standard | 3,000 promo (Jun 1 – Sep 1, 2026)
# Copilot Enterprise: 3,900 standard | 7,000 promo (Jun 1 – Sep 1, 2026)
# Note: credits are POOLED at the enterprise level, not per-user buckets.
# If your portal shows a different per-seat amount, set CREDITS_PER_SEAT_OVERRIDE.
# Code completions are NOT billed in AI credits — they are unlimited.
#
# Requires API version 2026-03-10 and the new Copilot usage metrics endpoints.
# The legacy /copilot/metrics and /copilot/usage endpoints were closed Apr 2, 2026.
#
# User-level AI credit budgets (GitHub Budgets API, enterprise-scoped):
# GET /enterprises/{enterprise}/settings/billing/budgets
# GET /enterprises/{enterprise}/settings/billing/budgets/{budget_id}/user-states
# Universal budget (budget_scope=multi_user_customer) applies to every user
# unless overridden.
# Individual budget (budget_scope=user) overrides the Universal budget for
# one specific user.
# Both are configured via Enterprise → Policies → Copilot → Budgets → Users.
# The user-states endpoint returns each user's effective target/consumed
# amount plus an override_budget_id when an Individual budget applies.
# Requires the token owner to be an enterprise admin or billing manager.
# Use --no-budgets to skip.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/github-common.sh
source "${SCRIPT_DIR}/../../lib/github-common.sh"
# ── Defaults ──────────────────────────────────────────────────────────────────
GITHUB_ENTERPRISE="${GITHUB_ENTERPRISE:-}"
GITHUB_TOKEN="${GITHUB_TOKEN:-}"
API_URL_PREFIX="${API_URL_PREFIX:-https://api.github.com}"
UPN_DOMAIN="${UPN_DOMAIN:-}"
ENTRA_TENANT="${ENTRA_TENANT:-}"
CREDITS_PER_SEAT_OVERRIDE="${CREDITS_PER_SEAT_OVERRIDE:-}"
OUTPUT_CSV="copilot-report-$(date +%Y%m%d).csv"
NO_ENTRA=false
NO_BUDGETS=false
GRAPH_TOKEN=""
ENTRA_ENABLED=false
# ── Derive UPN from GitHub login when no email is available ─────────────────
# Pattern: 'john_example' + domain 'example.com' → 'john@example.com'
# Strips everything from the last underscore onwards, then appends @domain.
derive_upn() {
local login="$1" domain="$2"
if [[ -z "$domain" ]]; then
echo "$login"
return
fi
# Remove trailing _{anything} suffix (last underscore and everything after)
local user="${login%_*}"
echo "${user}@${domain}"
}
# ── Plan → monthly AI credits contributed to the enterprise pool per seat ─────
# Credits are pooled — not individual user buckets.
# Standard amounts: business=1900, enterprise=3900
# Promo amounts (Jun 1 – Sep 1, 2026): business=3000, enterprise=7000
# Override all of this via $CREDITS_PER_SEAT_OVERRIDE if your portal shows
# a different value (e.g. 1000 if your enterprise is still on the old model).
IN_PROMO_PERIOD=false
_today=$(date +%Y%m%d)
if [[ "$_today" -ge "20260601" && "$_today" -lt "20260901" ]]; then
IN_PROMO_PERIOD=true
fi
plan_credits() {
[[ -n "$CREDITS_PER_SEAT_OVERRIDE" ]] && { echo "$CREDITS_PER_SEAT_OVERRIDE"; return; }
local promo="$IN_PROMO_PERIOD"
case "${1,,}" in
enterprise|pro_plus|proplus) [[ "$promo" == "true" ]] && echo "7000" || echo "3900" ;;
free) echo "50" ;;
*) [[ "$promo" == "true" ]] && echo "3000" || echo "1900" ;;
esac
}
# ── Help text ─────────────────────────────────────────────────────────────────
usage() {
cat <<'EOF'
Usage: github-copilot-report.sh [OPTIONS]
GitHub Copilot Enterprise licence + usage report, optionally enriched with
Entra ID department information.
Authentication (no secrets in flags):
GitHub → export GITHUB_TOKEN=ghp_yourtoken (read:enterprise,manage_billing:enterprise)
OR resolved automatically from an active gh auth session
Entra → az login
Options:
-e, --enterprise SLUG GitHub Enterprise slug (or $GITHUB_ENTERPRISE)
-d, --upn-domain DOM Email domain for Entra lookup when GitHub carries no
email address (or $UPN_DOMAIN, e.g. example.com)
Login 'john_example' + domain 'example.com' → john@example.com
The tenant ID is auto-resolved from this domain via
OIDC discovery, so --entra-tenant is rarely needed.
--entra-tenant ID Override the Azure AD tenant ID for the Graph lookup
(or $ENTRA_TENANT). Auto-resolved from --upn-domain
when not set; only needed to override that result.
--credits N Override credits-per-seat value (or $CREDITS_PER_SEAT_OVERRIDE)
Use if your portal shows a different pool size than expected
--output FILE Output CSV (default: copilot-report-YYYYMMDD.csv)
--no-entra Skip Entra ID department lookup
--no-budgets Skip per-user AI credit budget lookup (Universal/Individual)
-h, --help Show this message
Required GITHUB_TOKEN scopes:
read:enterprise
manage_billing:enterprise (requires enterprise owner or billing manager)
Note: If your enterprise has multiple organisations, the seats endpoint
aggregates across all of them automatically.
EOF
}
# ── Argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-e|--enterprise) GITHUB_ENTERPRISE="$2"; shift 2 ;;
-d|--upn-domain) UPN_DOMAIN="$2"; shift 2 ;;
--entra-tenant) ENTRA_TENANT="$2"; shift 2 ;;
--credits) CREDITS_PER_SEAT_OVERRIDE="$2"; shift 2 ;;
--output) OUTPUT_CSV="$2"; shift 2 ;;
--no-entra) NO_ENTRA=true; shift ;;
--no-budgets) NO_BUDGETS=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done
# ── Pre-flight checks ─────────────────────────────────────────────────────────
[[ -z "$GITHUB_ENTERPRISE" ]] && \
err "GitHub Enterprise slug is required (-e / \$GITHUB_ENTERPRISE)"
require_command jq
require_env_var GITHUB_TOKEN
validate_github_token "bearer"
# ── Acquire Microsoft Graph token via az CLI ──────────────────────────────────
if [[ "$NO_ENTRA" == "true" ]]; then
print_warning "Entra ID lookup disabled (--no-entra). Department column will be N/A."
elif ! command -v az &>/dev/null; then
print_warning "az CLI is not installed — department/division grouping will be skipped."
print_warning "Install the Azure CLI and run 'az login' to enable Entra ID enrichment, or pass --no-entra."
elif az account show &>/dev/null 2>&1; then
# Auto-resolve tenant ID from UPN domain via OIDC discovery when not explicitly set.
# GET https://login.microsoftonline.com/{domain}/.well-known/openid-configuration
# returns an issuer like https://sts.windows.net/{tenant-id}/ — extract the GUID.
if [[ -z "$ENTRA_TENANT" && -n "$UPN_DOMAIN" ]]; then
_resolved_tenant=$(curl -sf \
"https://login.microsoftonline.com/${UPN_DOMAIN}/.well-known/openid-configuration" \
2>/dev/null \
| jq -r '.issuer // empty' \
| grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') || true
if [[ -n "$_resolved_tenant" ]]; then
ENTRA_TENANT="$_resolved_tenant"
print_status "Auto-resolved Entra tenant '${ENTRA_TENANT}' from domain '${UPN_DOMAIN}'."
else
print_warning "Could not resolve tenant from domain '${UPN_DOMAIN}' — using default az tenant."
fi
fi
print_status "Acquiring Microsoft Graph token via az CLI..."
GRAPH_TOKEN=$(az account get-access-token \
--resource https://graph.microsoft.com \
${ENTRA_TENANT:+--tenant "$ENTRA_TENANT"} \
--query accessToken -o tsv 2>/dev/null) || true
if [[ -n "$GRAPH_TOKEN" ]]; then
ENTRA_ENABLED=true
[[ -n "$ENTRA_TENANT" ]] && print_status "Graph token acquired (tenant: ${ENTRA_TENANT})." \
|| print_status "Graph token acquired."
else
print_warning "az is logged in but could not get a Graph token — department lookup disabled."
print_warning "Ensure your account has User.Read.All permission in the target tenant."
[[ -z "$ENTRA_TENANT" ]] && \
print_warning "If you have multiple tenants, try: --entra-tenant <TENANT_ID>"
fi
else
print_warning "az CLI is not logged in — department/division grouping will be skipped."
print_warning "Run 'az login' to enable Entra ID enrichment, or pass --no-entra."
fi
# fetch_usage_ndjson REPORT_PATH
# Calls the new usage metrics API (which returns signed download_links to NDJSON
# files rather than inline JSON), downloads each file, and emits one NDJSON
# object per line on stdout. Exits cleanly with empty output if unavailable.
fetch_usage_ndjson() {
local path="$1"
local resp links
resp=$(gh_api "${path}" --api-version 2026-03-10 2>/dev/null) || return 0
[[ "${resp}" == "__404__" || "${resp}" == "__422__" ]] && return 0
links=$(echo "$resp" | jq -r '.download_links[]? // empty' 2>/dev/null) || return 0
[[ -z "$links" ]] && return 0
while IFS= read -r url; do
curl -sf "$url" 2>/dev/null || true
done <<< "$links"
}
# Fetches all active Copilot seats for the enterprise, returns a JSON array.
# The enterprise seats endpoint does not expose a total_seats wrapper, so
# TOTAL_ASSIGNED_SEATS falls back to the deduplicated unique-user count.
TOTAL_ASSIGNED_SEATS=0
fetch_seats() {
local slug="$1"
gh_api_paginate "/enterprises/${slug}/copilot/billing/seats" '.seats[]' '2026-03-10' \
| jq -s '.'
}
# ── Microsoft Graph helpers ───────────────────────────────────────────────────
declare -A _GRAPH_CACHE=()
# graph_user_info EMAIL_OR_UPN
# Returns JSON: {department, jobTitle, displayName, mail}
graph_user_info() {
local query="$1"
if [[ "$ENTRA_ENABLED" != "true" || -z "$query" ]]; then
echo '{"department":"N/A","jobTitle":"","displayName":"","mail":""}'
return
fi
if [[ -n "${_GRAPH_CACHE[$query]+_}" ]]; then
echo "${_GRAPH_CACHE[$query]}"
return
fi
# Use curl with the explicit GRAPH_TOKEN for reliable auth and proper URL encoding.
local resp
resp=$(curl -sfG \
-H "Authorization: Bearer ${GRAPH_TOKEN}" \
-H "ConsistencyLevel: eventual" \
--data-urlencode "\$filter=mail eq '${query}' or userPrincipalName eq '${query}'" \
--data-urlencode "\$select=displayName,department,jobTitle,mail,userPrincipalName" \
"https://graph.microsoft.com/v1.0/users" \
2>/dev/null) || resp='{"value":[]}'
local result
result=$(echo "$resp" | jq -c '
.value[0] // {} |
{
department: (.department // "Unknown"),
jobTitle: (.jobTitle // ""),
displayName: (.displayName // ""),
mail: (.mail // "")
}')
_GRAPH_CACHE["$query"]="$result"
echo "$result"
}
# ── Fetch seats ───────────────────────────────────────────────────────────────
print_status "Fetching Copilot seats for enterprise '${GITHUB_ENTERPRISE}'..."
SEATS_RAW=$(fetch_seats "$GITHUB_ENTERPRISE")
# The seats endpoint returns one entry per org per user — deduplicate by login,
# keeping the first occurrence (earliest org assignment).
# Collect all org assignments per user into a comma-separated "organisations" field.
SEATS=$(echo "$SEATS_RAW" | jq '
group_by(.assignee.login) |
map(
(.[0] | del(.organization)) +
{ organisations: (map(.organization.login // "") | map(select(. != "")) | unique | join(", ")) }
)
')
SEAT_COUNT=$(echo "$SEATS" | jq 'length')
print_status "Found ${SEAT_COUNT} unique licensed user(s) ($(echo "$SEATS_RAW" | jq 'length') raw seat entries across orgs)."
# Enterprise seats endpoint has no total_seats field; use unique user count.
TOTAL_ASSIGNED_SEATS=$SEAT_COUNT
# When 0 seats are returned, probe the endpoint directly to distinguish a genuine
# empty result (HTTP 200) from a silent auth/validation failure. gh_api_paginate
# swallows 404/422, so this probe is the only way to surface those to the user.
if [[ "$SEAT_COUNT" -eq 0 ]]; then
_probe_headers=$(mktemp)
_probe_code=$(curl -s -D "$_probe_headers" -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"${API_URL_PREFIX}/enterprises/${GITHUB_ENTERPRISE}/copilot/billing/seats?per_page=1")
# X-OAuth-Scopes is only present for classic PATs; empty for fine-grained PATs / GitHub Apps.
_token_scopes=$(grep -i "^x-oauth-scopes:" "$_probe_headers" | head -1 | cut -d: -f2- | tr -d ' \r' || true)
rm -f "$_probe_headers"
case "$_probe_code" in
200)
print_status "Seats endpoint returned HTTP 200 — enterprise has 0 Copilot seats assigned."
;;
404)
# GitHub returns 404 (not 403) on billing endpoints when manage_billing:enterprise scope is absent.
if [[ -n "$_token_scopes" ]] && echo "$_token_scopes" | tr ',' '\n' | grep -q "manage_billing:enterprise"; then
print_error "HTTP 404 — scopes look correct; verify enterprise slug '${GITHUB_ENTERPRISE}'."
else
print_error "HTTP 404 — likely missing 'manage_billing:enterprise' scope (GitHub returns 404, not 403, for this)."
print_error "Fix: classic PAT with read:enterprise + manage_billing:enterprise, or: gh auth refresh --scopes manage_billing:enterprise"
[[ -n "$_token_scopes" ]] && print_error "Current scopes: ${_token_scopes}"
fi
exit 1
;;
422)
print_error "Seats endpoint returned HTTP 422 — token may lack manage_billing:enterprise scope or the slug is invalid."
exit 1
;;
*)
print_warning "Seats endpoint returned HTTP ${_probe_code} — seat data may be incomplete."
;;
esac
fi
# ── Fetch per-user AI credit consumption (billing API, current month) ──────────
# Calls GET /enterprises/{slug}/settings/billing/ai_credit/usage?user={login}
# for each licensed user and sums grossQuantity across all usageItems.
# Requires manage_billing:enterprise scope (classic/OAuth token; not fine-grained PATs).
_BILLING_YEAR=$(date +%Y)
_BILLING_MONTH=$(( 10#$(date +%m) )) # strip leading zero (macOS compatible)
print_status "Fetching per-user AI credit consumption (billing API, ${_BILLING_YEAR}-$(printf '%02d' $_BILLING_MONTH))..."
declare -A USER_CREDITS_USED=()
declare -A USER_MODEL_CREDITS=() # key: "login|model" value: credits
declare -A _ALL_MODELS_SET=() # keys are unique model names seen
_billing_ok=true
_billing_fail_statuses=""
_ALL_LOGINS=$(echo "$SEATS" | jq -r '.[].assignee.login')
_LOGIN_COUNT=$(echo "$_ALL_LOGINS" | wc -l | tr -d ' ')
_login_i=0
while IFS= read -r _login; do
[[ -z "$_login" ]] && continue
_login_i=$(( _login_i + 1 ))
printf '\r [%d/%d] %s ' "$_login_i" "$_LOGIN_COUNT" "$_login" >&2
_resp=$(gh_api "/enterprises/${GITHUB_ENTERPRISE}/settings/billing/ai_credit/usage?user=${_login}&year=${_BILLING_YEAR}&month=${_BILLING_MONTH}" --api-version 2026-03-10) || _resp=""
if [[ "${_resp}" == "__404__" || "${_resp}" == "__422__" ]]; then
# No usage data this month — valid, treat as 0 credits.
_credits="0"
elif [[ -n "$_resp" ]]; then
# Success: parse credit usage
_credits=$(echo "$_resp" | jq '[.usageItems[]?.grossQuantity // 0] | add // 0 | round' 2>/dev/null || echo "0")
# Store per-model breakdown
while IFS=$'\t' read -r _model _qty; do
[[ -z "$_model" ]] && continue
USER_MODEL_CREDITS["${_login}|${_model}"]="$_qty"
_ALL_MODELS_SET["$_model"]=1
done < <(echo "$_resp" | jq -r '.usageItems[] | [(.model // "Unknown"), (.grossQuantity // 0 | round | tostring)] | @tsv' 2>/dev/null)
else
_credits="0"
_billing_ok=false
_billing_fail_statuses="${_billing_fail_statuses} ${_login}(empty)"
fi
USER_CREDITS_USED["$_login"]="$_credits"
done <<< "$_ALL_LOGINS"
printf '\r%-60s\r' '' >&2 # clear progress line
if [[ "$_billing_ok" == "true" ]]; then
print_status "Loaded billing data for ${#USER_CREDITS_USED[@]} user(s)."
else
print_warning "Some billing API calls failed — credits may show 0 for affected users."
print_warning "Ensure manage_billing:enterprise scope: set GITHUB_TOKEN with the required scopes"
[[ -n "$_billing_fail_statuses" ]] && \
print_warning "Failed users:${_billing_fail_statuses}"
fi
# ── Fetch enterprise AI credit budgets (Budgets API: Universal / Individual) ─
# Two calls total (each paginated), not one per user:
# 1. GET /enterprises/{enterprise}/settings/billing/budgets — locates the
# Universal (multi_user_customer) budget and any direct Individual
# (user-scope) budgets.
# 2. GET .../budgets/{universal_id}/user-states — per-user consumed/target
# amounts against the Universal budget; override_budget_id marks users
# who have an Individual override.
declare -A USER_BUDGET_SCOPE=() # "Individual" | "Universal" | "None"
declare -A USER_BUDGET_AMOUNT=() # USD
declare -A USER_BUDGET_USED=() # USD
if [[ "$NO_BUDGETS" == "true" ]]; then
print_warning "AI credit budget lookup disabled (--no-budgets)."
else
print_status "Fetching enterprise AI credit budgets (Universal / Individual)..."
_all_budgets="[]"
_page=1
while :; do
_bresp=$(gh_api "/enterprises/${GITHUB_ENTERPRISE}/settings/billing/budgets?per_page=100&page=${_page}" --api-version 2026-03-10) || _bresp=""
[[ "${_bresp}" == "__404__" || "${_bresp}" == "__422__" || -z "$_bresp" ]] && break
_all_budgets=$(jq -cn --argjson a "$_all_budgets" --argjson b "$(echo "$_bresp" | jq -c '.budgets // []')" '$a + $b')
[[ "$(echo "$_bresp" | jq -r '.has_next_page // false')" == "true" ]] || break
_page=$(( _page + 1 ))
done
_ai_budgets=$(echo "$_all_budgets" | jq -c '[.[] | select(.budget_product_sku=="ai_credits")]')
# Direct Individual (user-scope) budgets — covers the case where no Universal budget exists.
while IFS=$'\t' read -r _u _amt _used; do
[[ -z "$_u" ]] && continue
USER_BUDGET_SCOPE["$_u"]="Individual"
USER_BUDGET_AMOUNT["$_u"]="$_amt"
USER_BUDGET_USED["$_u"]="$_used"
done < <(echo "$_ai_budgets" | jq -r '.[] | select(.budget_scope=="user") | [.user, .budget_amount, (.effective_budget.consumed_amount // .consumed_amount // 0)] | @tsv')
_universal_id=$(echo "$_ai_budgets" | jq -r '[.[] | select(.budget_scope=="multi_user_customer")][0].id // empty')
_universal_amount=$(echo "$_ai_budgets" | jq -r '[.[] | select(.budget_scope=="multi_user_customer")][0].budget_amount // empty')
if [[ -n "$_universal_id" ]]; then
_page=1
while :; do
_sresp=$(gh_api "/enterprises/${GITHUB_ENTERPRISE}/settings/billing/budgets/${_universal_id}/user-states?per_page=100&page=${_page}" --api-version 2026-03-10) || _sresp=""
[[ "${_sresp}" == "__404__" || "${_sresp}" == "__422__" || -z "$_sresp" ]] && break
while IFS=$'\t' read -r _u _consumed _target _override; do
[[ -z "$_u" ]] && continue
if [[ -n "$_override" && "$_override" != "null" ]]; then
USER_BUDGET_SCOPE["$_u"]="Individual"
else
USER_BUDGET_SCOPE["$_u"]="Universal"
fi
USER_BUDGET_AMOUNT["$_u"]="$_target"
USER_BUDGET_USED["$_u"]="$_consumed"
done < <(echo "$_sresp" | jq -r '.user_states[]? | [.user, .consumed_amount, .target_amount, (.override_budget_id // "")] | @tsv')
[[ "$(echo "$_sresp" | jq -r '.has_next_page // false')" == "true" ]] || break
_page=$(( _page + 1 ))
done
# Licensed users covered by the Universal budget but absent from user-states (no usage yet)
while IFS= read -r _login; do
[[ -z "$_login" ]] && continue
[[ -n "${USER_BUDGET_SCOPE[$_login]+_}" ]] && continue
USER_BUDGET_SCOPE["$_login"]="Universal"
USER_BUDGET_AMOUNT["$_login"]="$_universal_amount"
USER_BUDGET_USED["$_login"]="0"
done <<< "$_ALL_LOGINS"
fi
if [[ "${#USER_BUDGET_SCOPE[@]}" -gt 0 ]]; then
print_status "Loaded budget data for ${#USER_BUDGET_SCOPE[@]} user(s)."
else
print_warning "No enterprise AI credit budgets are configured (Enterprise → Policies → Copilot → Budgets), or the token lacks enterprise admin/billing manager access."
fi
fi
# Build sorted list of all model names seen across all users
ALL_MODELS=()
while IFS= read -r _m; do
[[ -n "$_m" ]] && ALL_MODELS+=("$_m")
done < <(printf '%s\n' "${!_ALL_MODELS_SET[@]}" | sort)
# ── Fetch enterprise-level model usage (new usage metrics API, 28-day) ────────
print_status "Fetching enterprise model usage metrics (last 28 days)..."
ENT_NDJSON=$(fetch_usage_ndjson \
"/enterprises/${GITHUB_ENTERPRISE}/copilot/metrics/reports/enterprise-28-day/latest")
# Summarise: model → unique active users, features used
MODEL_SUMMARY="[]"
if [[ -n "$ENT_NDJSON" ]]; then
MODEL_SUMMARY=$(echo "$ENT_NDJSON" | jq -sc '
[ .[] | select(.model != null) |
{ model: (.model // "unknown"),
users: (.active_users // .total_active_users // .engaged_users // 0),
ctx: (.feature // .copilot_feature // "unknown") } ] |
group_by(.model) |
map({
model: .[0].model,
peak_users: (map(.users) | max),
contexts: (map(.ctx) | unique | join(", "))
}) |
sort_by(.model)
' 2>/dev/null || echo "[]")
fi
# ── Write CSV header ──────────────────────────────────────────────────────────
_model_header_cols=$(printf ',"Credits: %s"' "${ALL_MODELS[@]}")
printf '%s\n' \
"GitHub Login,Display Name,Email / UPN,Organisation,Plan Type,Pool Contribution (Credits/Seat),AI Credits Used (Month),Budget Scope,Budget Limit (USD),Budget Used (USD),Budget Remaining (USD)${_model_header_cols},Department,Job Title,Last Active,Last Editor" \
> "$OUTPUT_CSV"
# ── Per-department counters ───────────────────────────────────────────────────
declare -A DEPT_USERS=()
declare -A DEPT_CREDITS=()
TOTAL_CREDITS=0
# ── Process each seat ────────────────────────────────────────────────────────
print_status "Enriching ${SEAT_COUNT} users with Entra ID info (this may take a moment)..."
while IFS= read -r seat; do
login=$( echo "$seat" | jq -r '.assignee.login // ""')
email=$( echo "$seat" | jq -r '.assignee.email // ""')
gh_name=$( echo "$seat" | jq -r '.assignee.name // ""')
plan=$( echo "$seat" | jq -r '.plan_type // "business"')
org=$( echo "$seat" | jq -r '.organisations // ""')
last_active=$( echo "$seat" | jq -r '.last_activity_at // ""')
last_editor=$( echo "$seat" | jq -r '.last_activity_editor // ""')
credits=$(plan_credits "$plan")
credits_used="${USER_CREDITS_USED[$login]:-}"
budget_scope="${USER_BUDGET_SCOPE[$login]:-None}"
budget_amount="${USER_BUDGET_AMOUNT[$login]:-}"
budget_used="${USER_BUDGET_USED[$login]:-}"
budget_remaining=""
[[ "$budget_scope" != "None" ]] && \
budget_remaining=$(awk -v a="${budget_amount:-0}" -v u="${budget_used:-0}" 'BEGIN{printf "%.2f", a-u}')
# Prefer email; fall back to UPN derived from login + --upn-domain
if [[ -n "$email" ]]; then
lookup="$email"
elif [[ -n "$UPN_DOMAIN" ]]; then
lookup=$(derive_upn "$login" "$UPN_DOMAIN")
else
lookup="$login"
fi
user_info=$(graph_user_info "$lookup")
dept=$( echo "$user_info" | jq -r '.department // "Unknown"')
title=$( echo "$user_info" | jq -r '.jobTitle // ""')
disp=$( echo "$user_info" | jq -r '.displayName // ""')
[[ -z "$disp" ]] && disp="$gh_name"
# Append to CSV — double-quote all fields, escape embedded quotes
printf '"%s","%s","%s","%s","%s","%s","%s","%s","%s","%s","%s"' \
"${login//\"/\"\"}" \
"${disp//\"/\"\"}" \
"${lookup//\"/\"\"}" \
"${org//\"/\"\"}" \
"${plan//\"/\"\"}" \
"$credits" \
"${credits_used}" \
"${budget_scope}" \
"${budget_amount}" \
"${budget_used}" \
"${budget_remaining}" \
>> "$OUTPUT_CSV"
# Per-model credit columns (0 if user had no usage on that model)
for _m in "${ALL_MODELS[@]}"; do
printf ',"%s"' "${USER_MODEL_CREDITS[${login}|${_m}]:-0}" >> "$OUTPUT_CSV"
done
printf ',"%s","%s","%s","%s"\n' \
"${dept//\"/\"\"}" \
"${title//\"/\"\"}" \
"${last_active//\"/\"\"}" \
"${last_editor//\"/\"\"}" \
>> "$OUTPUT_CSV"
[[ -z "${DEPT_USERS[$dept]+x}" ]] && DEPT_USERS["$dept"]=0
[[ -z "${DEPT_CREDITS[$dept]+x}" ]] && DEPT_CREDITS["$dept"]=0
DEPT_USERS["$dept"]=$(( DEPT_USERS["$dept"] + 1 ))
DEPT_CREDITS["$dept"]=$(( DEPT_CREDITS["$dept"] + credits ))
TOTAL_CREDITS=$(( TOTAL_CREDITS + credits ))
done < <(echo "$SEATS" | jq -c '.[]')
# Total pool = all assigned seats × credits per seat.
# Uses the most common plan seen in active seats as the per-seat rate.
MOST_COMMON_PLAN=$(echo "$SEATS" | jq -r '[.[].plan_type] | group_by(.) | max_by(length) | .[0] // "business"')
CREDITS_PER_SEAT=$(plan_credits "$MOST_COMMON_PLAN")
TOTAL_ALLOCATED_CREDITS=$(( TOTAL_ASSIGNED_SEATS * CREDITS_PER_SEAT ))
PROMO_NOTE=""
[[ "$IN_PROMO_PERIOD" == "true" && -z "$CREDITS_PER_SEAT_OVERRIDE" ]] && \
PROMO_NOTE=" [promo rate Jun–Sep 2026]"
[[ -n "$CREDITS_PER_SEAT_OVERRIDE" ]] && \
PROMO_NOTE=" [override]"
# ── Console output ────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════════════════"
printf " GitHub Copilot Report · %s · Enterprise: %s\n" \
"$(date +%Y-%m-%d)" "$GITHUB_ENTERPRISE"
echo "═══════════════════════════════════════════════════════════════════"
# Department summary table
echo ""
printf " %-38s %7s %16s\n" "Department" "Users" "Allocated Credits"
printf " %-38s %7s %16s\n" "──────────────────────────────────────" "───────" "────────────────"
while IFS= read -r dept; do
[[ -z "$dept" ]] && continue # bash 5.3: ${!assoc[@]} on empty array expands to ""
printf " %-38s %7d %16d\n" "$dept" "${DEPT_USERS[$dept]:-0}" "${DEPT_CREDITS[$dept]:-0}"
done < <(printf '%s\n' "${!DEPT_USERS[@]}" | sort)
printf " %-38s %7s %16s\n" "──────────────────────────────────────" "───────" "────────────────"
printf " %-38s %7d %16d\n" "Active seats (subtotal)" "$SEAT_COUNT" "$TOTAL_CREDITS"
printf " %-38s %7d %16d\n" "All assigned seats (pool total)" "$TOTAL_ASSIGNED_SEATS" "$TOTAL_ALLOCATED_CREDITS"
printf " %s\n\n" " (${MOST_COMMON_PLAN} plan · ${CREDITS_PER_SEAT} credits/seat × ${TOTAL_ASSIGNED_SEATS} seats${PROMO_NOTE})"
# Model usage table
MODEL_COUNT=$(echo "$MODEL_SUMMARY" | jq 'length')
if [[ "$MODEL_COUNT" -gt 0 ]]; then
echo " ── Model usage (enterprise-level, last 28 days) ──────────────"
printf " %-42s %11s %s\n" "Model" "Peak Users" "Used in"
printf " %-42s %11s %s\n" \
"──────────────────────────────────────────" "──────────" "────────────────────────────────"
echo "$MODEL_SUMMARY" | jq -r '.[] | [.model, (.peak_users | tostring), .contexts] | @tsv' | \
while IFS=$'\t' read -r model users contexts; do
printf " %-42s %11s %s\n" "$model" "$users" "$contexts"
done
echo ""
fi
# Notes on credit accounting
echo " ── Notes ─────────────────────────────────────────────────────"
echo " Credits are POOLED at the enterprise level — the per-seat figure"
echo " shown above is each seat's contribution to the shared pool."
echo " Code completions are not billed in AI credits (unlimited)."
echo ""
echo " 'AI Credits Used (Month)' = actual AI credits consumed this billing month"
echo " (from GET /enterprises/.../settings/billing/ai_credit/usage)."
echo " 1 AI credit = \$0.01 USD. Code completions are not billed (unlimited)."
echo " A value of 0 means no metered AI usage this month."
echo ""
print_success "Report saved → ${OUTPUT_CSV}"
echo ""