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
134 changes: 132 additions & 2 deletions src/lib/csv-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,90 @@ describe('detectReportType', () => {
});

it('handles case-insensitive headers', () => {
const headers = ['Date', 'AIC_QUANTITY', 'AIC_Gross_Amount', 'username'];
const headers = ['Date', 'MODEL', 'Exceeds_Quota', 'TOTAL_monthly_quota'];
expect(detectReportType(headers)).toBe(REPORT_TYPES.PREMIUM_REQUEST);
});

it('handles headers with whitespace padding', () => {
const headers = [' date ', ' aic_quantity ', ' aic_gross_amount', 'username'];
const headers = [' date ', ' model ', ' exceeds_quota', 'total_monthly_quota '];
expect(detectReportType(headers)).toBe(REPORT_TYPES.PREMIUM_REQUEST);
});

it('detects the summarized usage report, which omits username and workflow_path', () => {
const headers = [
'date',
'product',
'sku',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'organization',
'repository',
'cost_center_name',
];
expect(detectReportType(headers)).toBe(REPORT_TYPES.USAGE_REPORT);
});

it('detects a premium request report that has no aic_ columns', () => {
const headers = [
'date',
'username',
'product',
'sku',
'model',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'exceeds_quota',
'total_monthly_quota',
'organization',
'cost_center_name',
];
expect(detectReportType(headers)).toBe(REPORT_TYPES.PREMIUM_REQUEST);
});

it('does not mistake a Copilot report for a metered usage report', () => {
const headers = [
'date',
'username',
'product',
'sku',
'model',
'quantity',
'unit_type',
'applied_cost_per_quantity',
'gross_amount',
'discount_amount',
'net_amount',
'exceeds_quota',
'total_monthly_quota',
'organization',
'cost_center_name',
'total_input_tokens',
'total_output_tokens',
'total_cache_creation_tokens',
'total_cache_read_tokens',
];
expect(detectReportType(headers)).toBe(REPORT_TYPES.TOKEN_USAGE);
});

it('detects the org-level seat activity export, which has no Organization column', () => {
const headers = [
'report time',
'login',
'last authenticated at',
'last activity at',
'last surface used',
];
expect(detectReportType(headers)).toBe(REPORT_TYPES.COPILOT_SEAT_ACTIVITY);
});

it('throws on unknown headers', () => {
expect(() => detectReportType(['foo', 'bar', 'baz'])).toThrow('Unknown report type');
});
Expand Down Expand Up @@ -895,3 +970,58 @@ describe('cross-report: all 6 report types are uniquely detectable', () => {
}
});
});

// ─── Billing Format Drift Regressions ──────────────────────────────────────────

describe('parseCSV — billing format drift', () => {
it('parses a summarized metered usage report end to end', () => {
const csv = [
'"date","product","sku","quantity","unit_type","applied_cost_per_quantity","gross_amount","discount_amount","net_amount","organization","repository","cost_center_name"',
'"2026-02-01","actions","actions_linux","1200","minutes","0.008","9.6","0","9.6","acme-platform","acme-platform/api",""',
'"2026-02-02","actions","actions_macos","30","minutes","0.08","2.4","0","2.4","acme-platform","acme-platform/ios","engineering"',
].join('\n');

const report = parseCSV(csv, 'summarized.csv');

expect(report.type).toBe(REPORT_TYPES.USAGE_REPORT);
expect(report.rowCount).toBe(2);
expect(report.dateRange).toEqual({ start: '2026-02-01', end: '2026-02-02' });

const rows = report.rows as UsageReportRow[];
expect(rows[0].quantity).toBe(1200);
expect(rows[0].repository).toBe('acme-platform/api');
// Columns absent from the summarized export fall back to empty strings.
expect(rows[0].username).toBe('');
expect(rows[0].workflowPath).toBe('');
});

it('parses AI-credit billed Copilot rows', () => {
const csv = [
'"date","username","product","sku","model","quantity","unit_type","applied_cost_per_quantity","gross_amount","discount_amount","net_amount","exceeds_quota","total_monthly_quota","organization","cost_center_name","aic_quantity","aic_gross_amount"',
'"2026-07-01","gray-oak","copilot","copilot_ai_credit","Claude Opus 4.6","250","ai-credits","0.01","2.5","0","2.5","False","1000","alder-labs","","250","2.5"',
].join('\n');

const report = parseCSV(csv, 'ai-credits.csv');

expect(report.type).toBe(REPORT_TYPES.PREMIUM_REQUEST);
const rows = report.rows as PremiumRequestRow[];
expect(rows[0].sku).toBe('copilot_ai_credit');
expect(rows[0].unitType).toBe('ai-credits');
expect(rows[0].quantity).toBe(250);
expect(rows[0].aicQuantity).toBe(250);
});

it('parses an org-level seat activity export with no Organization column', () => {
const csv = [
'Report Time,Login,Last Authenticated At,Last Activity At,Last Surface Used',
'2026-03-28T06:54:33Z,val-wynn,2026-02-25T10:12:31Z,2026-02-07T17:13:59Z,vscode/1.112.0',
].join('\n');

const report = parseCSV(csv, 'seat-activity-org.csv');

expect(report.type).toBe(REPORT_TYPES.COPILOT_SEAT_ACTIVITY);
const rows = report.rows as CopilotSeatActivityRow[];
expect(rows[0].login).toBe('val-wynn');
expect(rows[0].organization).toBe('');
});
});
36 changes: 22 additions & 14 deletions src/lib/csv-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,32 @@ import type {
} from './types';
import { REPORT_TYPES } from './types';

/** CSV header → report type mapping. Order matters — check most specific first. */
const HEADER_SIGNATURES: Record<ReportType, string[]> = {
[REPORT_TYPES.TOKEN_USAGE]: ['total_input_tokens', 'total_output_tokens'],
[REPORT_TYPES.PREMIUM_REQUEST]: ['aic_quantity', 'aic_gross_amount'],
[REPORT_TYPES.USAGE_REPORT]: ['repository', 'workflow_path'],
[REPORT_TYPES.GHAS_ACTIVE_COMMITTERS]: ['user login', 'organization / repository', 'last pushed date'],
[REPORT_TYPES.COPILOT_SEAT_ACTIVITY]: ['report time', 'last authenticated at', 'last activity at', 'last surface used'],
[REPORT_TYPES.ENTERPRISE_MEMBERS]: ['github com login', 'license type', 'github com enterprise roles', 'total user accounts'],
[REPORT_TYPES.DORMANT_USERS]: ['login', 'role', '2fa_enabled?', 'outside_collaborator'],
};
/**
* CSV header signatures → report type. Evaluated in array order, most specific
* first, because several reports share the billing column prefix.
*
* Signatures list only columns that are structurally guaranteed for that report.
* The metered usage report ships in two flavours — detailed (31 days, includes
* `username` and `workflow_path`) and summarized (up to a year, omits both) — so
* it keys off `repository`, which no Copilot report has.
*/
const HEADER_SIGNATURES: ReadonlyArray<readonly [ReportType, readonly string[]]> = [
[REPORT_TYPES.TOKEN_USAGE, ['total_input_tokens', 'total_output_tokens']],
[REPORT_TYPES.PREMIUM_REQUEST, ['model', 'exceeds_quota', 'total_monthly_quota']],
[REPORT_TYPES.USAGE_REPORT, ['date', 'product', 'sku', 'net_amount', 'repository']],
[REPORT_TYPES.GHAS_ACTIVE_COMMITTERS, ['user login', 'organization / repository', 'last pushed date']],
[REPORT_TYPES.COPILOT_SEAT_ACTIVITY, ['report time', 'last authenticated at', 'last activity at', 'last surface used']],
[REPORT_TYPES.ENTERPRISE_MEMBERS, ['github com login', 'license type', 'github com enterprise roles', 'total user accounts']],
[REPORT_TYPES.DORMANT_USERS, ['login', 'role', '2fa_enabled?', 'outside_collaborator']],
];

/** Detect report type from CSV headers */
export function detectReportType(headers: string[]): ReportType {
const lowerHeaders = headers.map((h) => h.toLowerCase().trim());

for (const [type, signatures] of Object.entries(HEADER_SIGNATURES)) {
for (const [type, signatures] of HEADER_SIGNATURES) {
if (signatures.every((sig) => lowerHeaders.includes(sig))) {
return type as ReportType;
return type;
}
}

Expand Down Expand Up @@ -61,7 +69,7 @@ function mapPremiumRequestRow(raw: Record<string, string>): PremiumRequestRow {
sku: (raw['sku'] ?? '') as PremiumRequestSku,
model: raw['model'] ?? '',
quantity: parseNum(raw['quantity']),
unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units',
unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units' | 'ai-credits',
appliedCostPerQuantity: parseNum(raw['applied_cost_per_quantity']),
grossAmount: parseNum(raw['gross_amount']),
discountAmount: parseNum(raw['discount_amount']),
Expand All @@ -84,7 +92,7 @@ function mapTokenUsageRow(raw: Record<string, string>): TokenUsageRow {
sku: (raw['sku'] ?? '') as PremiumRequestSku,
model: raw['model'] ?? '',
quantity: parseNum(raw['quantity']),
unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units',
unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units' | 'ai-credits',
appliedCostPerQuantity: parseNum(raw['applied_cost_per_quantity']),
grossAmount: parseNum(raw['gross_amount']),
discountAmount: parseNum(raw['discount_amount']),
Expand Down
5 changes: 4 additions & 1 deletion src/lib/report-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ export const PRODUCT_METRIC_OPTIONS: Record<string, MetricOption[]> = {
copilot: [
{ key: 'grossAmount', label: 'Spend', isCurrency: true },
{ key: 'seats', label: 'Seats', isCurrency: false, valueField: 'quantity', rowFilter: (r) => r.unitType === 'user-months' },
{ key: 'usage', label: 'Usage (PRUs)', isCurrency: false, valueField: 'quantity', rowFilter: (r) => r.unitType === 'requests' },
// Anything not billed per seat is consumption. Matching on "not user-months"
// rather than an allow-list keeps this working across the PRU → AI credits
// billing change, which introduced new unit types (ai-units, ai-credits).
{ key: 'usage', label: 'Usage', isCurrency: false, valueField: 'quantity', rowFilter: (r) => r.unitType !== 'user-months' },
],
spark: [
{ key: 'grossAmount', label: 'Spend', isCurrency: true },
Expand Down
18 changes: 14 additions & 4 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export type PremiumRequestSku =
| 'spark_premium_request'
| 'copilot_ai_unit'
| 'coding_agent_ai_unit'
| 'spark_ai_unit';
| 'spark_ai_unit'
| 'copilot_ai_credit'
| 'coding_agent_ai_credit'
| 'spark_ai_credit';

// ─── Usage Report Product & SKU ────────────────────────────────────────────────

Expand Down Expand Up @@ -97,7 +100,14 @@ export type UsageSku =
| (string & {});

/** Unit types for metered usage */
export type UsageUnitType = 'minutes' | 'gigabyte-hours' | 'gigabytes' | 'requests' | 'user-months' | 'ai-units';
export type UsageUnitType =
| 'minutes'
| 'gigabyte-hours'
| 'gigabytes'
| 'requests'
| 'user-months'
| 'ai-units'
| 'ai-credits';

// ─── CSV Column Name Mappings (raw header → camelCase) ─────────────────────────

Expand Down Expand Up @@ -201,8 +211,8 @@ interface BaseCopilotReportRow { /** ISO date string (YYYY-MM-DD) */
model: CopilotModelValue;
/** Number of premium requests or AI units consumed */
quantity: number;
/** "requests" for premium request billing, "ai-units" for AIU billing */
unitType: 'requests' | 'ai-units';
/** "requests" for PRU billing, "ai-units"/"ai-credits" for AI credit billing */
unitType: 'requests' | 'ai-units' | 'ai-credits';
/** Cost per request (typically 0.04) */
appliedCostPerQuantity: number;
/** Total cost before discounts */
Expand Down
Loading