costgrep-cli 0.0.0-stage → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,993 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * costgrep — GitHub Actions cost breakdown: human vs AI-agent vs bot.
5
+ *
6
+ * Zero-dependency (Node >= 18). Runs on YOUR runner, talks only to
7
+ * api.github.com with the token you pass. No telemetry, no data leaves your infra.
8
+ *
9
+ * Methodology (public, see README):
10
+ * minutes(job) = ceil((completed_at - started_at) / 60s), min 1 per started job
11
+ * (GitHub rounds each job up to a whole minute)
12
+ * cost(job) = minutes x rate[runner class] (list-price matrix, docs.github.com,
13
+ * effective 2026-01-01; NOT the invoice — included plan minutes
14
+ * are consumed first; reconciliation vs billing API is the hosted phase)
15
+ * attribution = run.triggering_actor (falls back to run.actor), classified as
16
+ * agent | human | bot via slug lists (overridable via --config)
17
+ * self-hosted = $0 while the $0.002/min platform fee is postponed
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs';
21
+ import { parseArgs } from 'node:util';
22
+
23
+ const API = process.env.COSTGREP_API || 'https://api.github.com'; // env override exists for local-mock verification only
24
+ const HTTP_TIMEOUT = Math.max(1_000, parseInt(process.env.COSTGREP_TIMEOUT_MS, 10) || 30_000);
25
+ const VERSION = '0.7.4'; // keep in sync with package.json
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Rate matrix — GitHub-hosted runners, list prices effective 2026-01-01.
29
+ // Source: docs.github.com/en/billing/reference/actions-runner-pricing (SKUid names
30
+ // match billing usage reports, which is what the later reconciliation joins on).
31
+ // Override anything via --config {"rates": {...}}.
32
+ // ---------------------------------------------------------------------------
33
+ const DEFAULT_RATES = {
34
+ 'actions_linux_slim': 0.002, // Linux 1-core x64
35
+ 'actions_linux': 0.006, // Linux 2-core x64
36
+ 'actions_linux_arm': 0.005, // Linux 2-core arm64
37
+ 'actions_windows': 0.010, // Windows 2-core x64
38
+ 'actions_windows_arm': 0.010, // Windows 2-core arm64
39
+ 'actions_macos': 0.062, // macOS 3/4-core
40
+ 'macos_l': 0.077, // macOS 12-core
41
+ 'macos_xl': 0.102, // macOS 5-core M2 Pro
42
+ 'self_hosted': 0.0, // $0.002/min platform fee postponed as of 2026-10
43
+ larger: {
44
+ x64: {
45
+ linux: { 4: 0.012, 8: 0.022, 16: 0.042, 32: 0.082, 64: 0.162, 96: 0.252 },
46
+ windows: { 4: 0.022, 8: 0.042, 16: 0.082, 32: 0.162, 64: 0.322, 96: 0.552 },
47
+ },
48
+ arm: {
49
+ linux: { 2: 0.005, 4: 0.008, 8: 0.014, 16: 0.026, 32: 0.050, 64: 0.098 },
50
+ windows: { 2: 0.008, 4: 0.014, 8: 0.026, 16: 0.050, 32: 0.098, 64: 0.194 },
51
+ },
52
+ },
53
+ gpu: { linux: 0.052, windows: 0.102 }, // 4-core GPU
54
+ };
55
+
56
+ // Logins (normalized: lowercase, "[bot]" stripped) classified as AI coding agents /
57
+ // AI reviewers. Contains-match. Extend via --config {"agents": [...]}.
58
+ const DEFAULT_AGENTS = [
59
+ 'copilot', 'copilot-swe-agent', 'claude', 'cursor', 'codex', 'openai-codex',
60
+ 'gemini', 'gemini-code-assist', 'jules', 'devin', 'aider', 'windsurf',
61
+ 'codebuff', 'opencode', 'goose', 'factory', 'sweep', 'coderabbit',
62
+ 'qodo', 'greptile', 'ellipsis', 'codeant', 'kodu', 'ampcode',
63
+ ];
64
+
65
+ // Automation bots (exact match after normalization). Everything else with
66
+ // actor.type == "Bot" / login ending in "[bot]" is also treated as a bot.
67
+ const DEFAULT_BOTS = [
68
+ 'github-actions', 'actions-user', 'dependabot', 'renovate', 'greenkeeper',
69
+ 'imgbot', 'allcontributors', 'lock', 'stash', 'bors', 'mergify',
70
+ 'semantic-release-bot', 'codecov', 'coveralls', 'netlify', 'vercel',
71
+ 'github-advanced-security', 'fossa', 'changeset-bot',
72
+ ];
73
+
74
+ // Co-Authored-By trailer names (normalized) treated as agent co-authorship.
75
+ // Fallback mirror of agents.json for single-file deployments.
76
+ const DEFAULT_COAUTHORS = [
77
+ 'copilot', 'claude', 'cursor', 'gemini', 'codex', 'devin', 'aider',
78
+ 'windsurf', 'jules', 'goose', 'codebuff', 'opencode',
79
+ ];
80
+
81
+ const CLASS_ORDER = ['agent', 'agent-assisted', 'human', 'bot', 'unattributed'];
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // CLI
85
+ // ---------------------------------------------------------------------------
86
+ function parseCli() {
87
+ const { values } = parseArgs({
88
+ options: {
89
+ repo: { type: 'string' }, // owner/name; default $GITHUB_REPOSITORY
90
+ org: { type: 'string' }, // org-wide mode: audit all visible repos of this org/user
91
+ repos: { type: 'string', default: '20' }, // org mode: how many repos (sorted by last push)
92
+ days: { type: 'string', default: '30' },
93
+ token: { type: 'string' }, // default $GITHUB_TOKEN (optional for public repos)
94
+ 'max-runs': { type: 'string' }, // per repo; default 1000 (org mode: 100)
95
+ config: { type: 'string' }, // JSON file: {agents, bots, rates, coab}
96
+ 'no-coab': { type: 'boolean', default: false }, // disable Co-Authored-By agent-assisted detection
97
+ 'pr-comment': { type: 'string' }, // post the report as a PR comment: number or 'auto'
98
+ 'fixture-dir': { type: 'string' }, // offline mode: runs.json with embedded jobs
99
+ 'json-file': { type: 'string' }, // write full report JSON here (incl. evidence[] + provenance)
100
+ 'md-file': { type: 'string' }, // write shareable Markdown report with provenance block
101
+ 'csv-file': { type: 'string' }, // write per-job evidence CSV (audit trail)
102
+ 'gh-output': { type: 'string' }, // append KEY=VALUE outputs to this file (GitHub $GITHUB_OUTPUT)
103
+ 'slack-webhook': { type: 'string' }, // post the report to a Slack incoming webhook (https://hooks.slack.com/services/...)
104
+ 'step-summary': { type: 'boolean', default: false },
105
+ quiet: { type: 'boolean', default: false },
106
+ help: { type: 'boolean', default: false },
107
+ },
108
+ });
109
+ if (values.help) {
110
+ console.log(`costgrep v${VERSION} (Apache-2.0)
111
+ usage: costgrep.mjs [reconcile|credits] | [--repo owner/name | --org NAME [--repos N]] [--days 30] [--token TOKEN]
112
+ [--max-runs N] [--config FILE] [--no-coab] [--pr-comment N|auto] [--slack-webhook URL] [--fixture-dir DIR]
113
+ [--json-file FILE] [--md-file FILE] [--csv-file FILE]
114
+ [--gh-output FILE] [--step-summary] [--quiet]`);
115
+ process.exit(0);
116
+ }
117
+ values.days = Math.max(1, parseInt(values.days, 10) || 30);
118
+ values.repos = Math.max(1, parseInt(values.repos, 10) || 20);
119
+ values['max-runs'] = parseInt(values['max-runs'] ?? '', 10) || (values.org ? 100 : 1000);
120
+ values.repo = values.repo || (values['fixture-dir'] || values.org ? null : process.env.GITHUB_REPOSITORY);
121
+ if (values.repo && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(values.repo)) {
122
+ throw new Error(`invalid --repo "${values.repo}" — expected owner/name (letters, digits, . _ -)`);
123
+ }
124
+ if (values.org && !/^[A-Za-z0-9-]+$/.test(values.org)) {
125
+ throw new Error(`invalid --org "${values.org}" — expected an org/user login`);
126
+ }
127
+ return values;
128
+ }
129
+
130
+ function loadConfig(path) {
131
+ const cfg = {
132
+ agents: [...DEFAULT_AGENTS], bots: [...DEFAULT_BOTS], rates: DEFAULT_RATES,
133
+ coab: true, coauthorNames: [...DEFAULT_COAUTHORS],
134
+ };
135
+ // The bundled agents.json extends the baked-in lists — data PRs don't touch code.
136
+ try {
137
+ const bundled = JSON.parse(readFileSync(new URL('../agents.json', import.meta.url), 'utf8'));
138
+ if (bundled.agents) cfg.agents = [...new Set([...cfg.agents, ...bundled.agents.map(norm)])];
139
+ if (bundled.bots) cfg.bots = [...new Set([...cfg.bots, ...bundled.bots.map(norm)])];
140
+ if (bundled.coauthorNames) cfg.coauthorNames = [...new Set([...cfg.coauthorNames, ...bundled.coauthorNames.map(norm)])];
141
+ } catch { /* single-file deployments keep the baked-in defaults */ }
142
+ if (!path) return cfg;
143
+ if (!existsSync(path)) throw new Error(`config file not found: ${path}`);
144
+ const user = JSON.parse(readFileSync(path, 'utf8'));
145
+ if (user.agents) cfg.agents = [...new Set([...cfg.agents, ...user.agents.map(norm)])];
146
+ if (user.bots) cfg.bots = [...new Set([...cfg.bots, ...user.bots.map(norm)])];
147
+ if (user.coauthorNames) cfg.coauthorNames = [...new Set([...cfg.coauthorNames, ...user.coauthorNames.map(norm)])];
148
+ if (user.coab === false) cfg.coab = false;
149
+ if (user.rates) cfg.rates = { ...DEFAULT_RATES, ...user.rates, larger: { ...DEFAULT_RATES.larger, ...(user.rates.larger || {}) } };
150
+ return cfg;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // GitHub API (paginated, one retry on rate limit)
155
+ // ---------------------------------------------------------------------------
156
+ async function gh(path, token) {
157
+ const headers = {
158
+ Accept: 'application/vnd.github+json',
159
+ 'X-GitHub-Api-Version': '2022-11-28',
160
+ 'User-Agent': 'costgrep',
161
+ };
162
+ if (token) headers.Authorization = `Bearer ${token}`;
163
+ for (let attempt = 0; ; attempt++) {
164
+ let res;
165
+ try {
166
+ res = await fetch(`${API}${path}`, { headers, signal: AbortSignal.timeout(HTTP_TIMEOUT) });
167
+ } catch (e) {
168
+ if (e.name === 'TimeoutError' || e.name === 'AbortError') {
169
+ throw new Error(`GitHub API timeout on ${path} after ${HTTP_TIMEOUT / 1000}s (set COSTGREP_TIMEOUT_MS to override)`);
170
+ }
171
+ throw new Error(`network error on ${path}: ${e.message}`);
172
+ }
173
+ if (res.status === 403 || res.status === 429) {
174
+ const reset = Number(res.headers.get('x-ratelimit-reset') || 0) * 1000;
175
+ if (attempt === 0) {
176
+ const wait = Math.min(Math.max(reset - Date.now(), 2000), 60_000);
177
+ if (!process.env.COSTGREP_NO_WAIT) {
178
+ console.error(`rate limit hit, retrying in ${Math.round(wait / 1000)}s`);
179
+ await new Promise(r => setTimeout(r, wait));
180
+ continue;
181
+ }
182
+ }
183
+ throw new Error(`GitHub API rate limited on ${path} (403/429). Pass --token or narrow --days/--max-runs.`);
184
+ }
185
+ if (!res.ok) throw new Error(`GitHub API ${res.status} on ${path}: ${(await res.text()).slice(0, 300)}`);
186
+ return res.json();
187
+ }
188
+ }
189
+
190
+ async function ghPost(path, token, body) {
191
+ const res = await fetch(`${API}${path}`, {
192
+ method: 'POST',
193
+ headers: {
194
+ Accept: 'application/vnd.github+json',
195
+ Authorization: `Bearer ${token}`,
196
+ 'Content-Type': 'application/json',
197
+ 'X-GitHub-Api-Version': '2022-11-28',
198
+ 'User-Agent': 'costgrep',
199
+ },
200
+ body: JSON.stringify(body),
201
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
202
+ });
203
+ if (!res.ok) throw new Error(`GitHub API ${res.status} on ${path}: ${(await res.text()).slice(0, 200)}`);
204
+ return res.json();
205
+ }
206
+
207
+ async function listOrgRepos(org, token, limit) {
208
+ const out = [];
209
+ for (let page = 1; ; page++) {
210
+ const q = `?sort=pushed&direction=desc&per_page=100&page=${page}`;
211
+ let data;
212
+ try {
213
+ data = await gh(`/orgs/${org}/repos${q}`, token);
214
+ } catch (e) {
215
+ if (String(e.message).includes(' 404 ')) data = await gh(`/users/${org}/repos${q}`, token); // user account
216
+ else throw e;
217
+ }
218
+ for (const r of (Array.isArray(data) ? data : [])) {
219
+ out.push(r.full_name);
220
+ if (out.length >= limit) return out;
221
+ }
222
+ if (!Array.isArray(data) || data.length < 100) return out;
223
+ }
224
+ }
225
+
226
+ async function listRuns(repo, sinceIso, token, maxRuns, untilIso) {
227
+ const runs = [];
228
+ for (let page = 1; ; page++) {
229
+ const data = await gh(`/repos/${repo}/actions/runs?per_page=100&page=${page}`, token);
230
+ const batch = data.workflow_runs || [];
231
+ for (const r of batch) {
232
+ const t = r.created_at && new Date(r.created_at);
233
+ if (t && t < sinceIso) return runs; // sorted newest-first
234
+ if (untilIso && t && t >= untilIso) continue; // past the window (recent first)
235
+ runs.push(r);
236
+ if (runs.length >= maxRuns) return runs;
237
+ }
238
+ if (runs.length >= (data.total_count ?? 0) || batch.length < 100) return runs;
239
+ }
240
+ }
241
+
242
+ async function listJobs(repo, runId, token) {
243
+ const jobs = [];
244
+ for (let page = 1; ; page++) {
245
+ const data = await gh(`/repos/${repo}/actions/runs/${runId}/jobs?per_page=100&page=${page}`, token);
246
+ jobs.push(...(data.jobs || []));
247
+ if (jobs.length >= (data.total_count ?? 0) || (data.jobs || []).length < 100) return jobs;
248
+ }
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Attribution
253
+ // ---------------------------------------------------------------------------
254
+ function norm(login) {
255
+ return String(login || '').toLowerCase().replace(/\[bot\]$/, '').trim();
256
+ }
257
+
258
+ // Co-Authored-By trailers in the head commit message: a human-triggered run whose
259
+ // commit was co-authored by an AI agent. Known limit (stated in methodology): some
260
+ // editors (e.g. VS Code) auto-insert the Copilot trailer — so this is a separate
261
+ // `agent-assisted` class, never silently merged into `agent`.
262
+ function coAuthoredByAgent(message, cfg) {
263
+ if (!cfg.coab || !message) return false;
264
+ const trailers = String(message).match(/co-authored-by:[^\r\n]+/gi) || [];
265
+ return trailers.some(t => cfg.coauthorNames.some(n => norm(t).includes(norm(n))));
266
+ }
267
+
268
+ function classifyActor(run, cfg) {
269
+ // triggering_actor is who effectively caused this execution: the re-runner for
270
+ // re-runs, and the honest identity for workflow_run chains (github.actor may
271
+ // resolve to a generic bot there — see github/gh-aw#20586).
272
+ const src = run.triggering_actor?.login ? run.triggering_actor : run.actor;
273
+ const login = src?.login || '';
274
+ const type = src?.type || '';
275
+ const n = norm(login);
276
+ if (!n) return { login, klass: 'unattributed' };
277
+ if (cfg.agents.some(a => n.includes(norm(a)))) return { login, klass: 'agent' };
278
+ if (type === 'User') {
279
+ if (coAuthoredByAgent(run.head_commit?.message, cfg)) return { login, klass: 'agent-assisted' };
280
+ return { login, klass: 'human' };
281
+ }
282
+ if (cfg.bots.includes(n) || type === 'Bot' || login.endsWith('[bot]')) return { login, klass: 'bot' };
283
+ return { login, klass: 'unattributed' };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Cost engine
288
+ // ---------------------------------------------------------------------------
289
+ function jobMinutes(job) {
290
+ if (!job.started_at) return 0; // queued/skipped jobs never ran
291
+ if (!job.completed_at) return 0; // in progress — excluded, counted separately
292
+ const ms = new Date(job.completed_at) - new Date(job.started_at);
293
+ return Math.max(1, Math.ceil(ms / 60_000)); // GitHub rounds each job up
294
+ }
295
+
296
+ function inferRate(job, cfg) {
297
+ const labels = (job.labels || []).map(l => String(l).toLowerCase());
298
+ const has = s => labels.some(l => l.includes(s));
299
+ const cores = (() => {
300
+ for (const l of labels) {
301
+ const m = l.match(/(?:^|[-_ ])(\d{1,2})[-_ ]?core/);
302
+ if (m) return Number(m[1]);
303
+ }
304
+ return null;
305
+ })();
306
+ const arm = has('arm64') || has('arm');
307
+ const r = cfg.rates;
308
+
309
+ if (labels.includes('self-hosted')) return { sku: 'self_hosted', rate: r.self_hosted, fallback: false };
310
+ if (has('macos')) {
311
+ if (has('xlarge') || has('xl') || has('m2')) return { sku: 'macos_xl', rate: r.macos_xl, fallback: false };
312
+ if (has('large') || cores === 12) return { sku: 'macos_l', rate: r.macos_l, fallback: false };
313
+ return { sku: 'actions_macos', rate: r.actions_macos, fallback: false };
314
+ }
315
+ if (has('gpu')) {
316
+ const fam = has('windows') ? 'windows' : 'linux';
317
+ return { sku: `${fam}_gpu`, rate: r.gpu[fam], fallback: false };
318
+ }
319
+ const isWindows = has('windows');
320
+ const isLinux = has('ubuntu') || has('linux');
321
+ if (isWindows || isLinux) {
322
+ const fam = isWindows ? 'windows' : 'linux';
323
+ if (cores && cores > 2) {
324
+ const table = r.larger[arm ? 'arm' : 'x64'][fam];
325
+ if (table[cores] != null) return { sku: `${fam}_${cores}_core`, rate: table[cores], fallback: false };
326
+ }
327
+ if (fam === 'linux') {
328
+ if (has('slim') || cores === 1) return { sku: 'actions_linux_slim', rate: r.actions_linux_slim, fallback: false };
329
+ if (arm) return { sku: 'actions_linux_arm', rate: r.actions_linux_arm, fallback: false };
330
+ }
331
+ if (fam === 'windows') return { sku: 'actions_windows', rate: r.actions_windows, fallback: false };
332
+ return { sku: 'actions_linux', rate: r.actions_linux, fallback: false };
333
+ }
334
+ // Unknown runner: fall back to the standard Linux rate and flag it — never
335
+ // silently guess an expensive rate.
336
+ return { sku: 'actions_linux', rate: r.actions_linux, fallback: true };
337
+ }
338
+
339
+ async function repoVisibility(repo, token) {
340
+ try {
341
+ const r = await gh(`/repos/${repo}`, token);
342
+ return r.private ? 'private' : 'public';
343
+ } catch { return 'unknown'; }
344
+ }
345
+
346
+ // Standard Linux/Windows hosted minutes are FREE for public repositories;
347
+ // macOS and larger runners are billed even there. Say so explicitly —
348
+ // a list-price figure must never read as "money you owe".
349
+ function visibilityNotice(rep) {
350
+ const v = rep.provenance.repoVisibility;
351
+ const billed = rep.provenance.billedEvenIfPublic || { jobs: 0, cost: 0 };
352
+ const note = (extra) =>
353
+ `>>> ${v} repo: standard Linux/Windows minutes are ${v === 'public' ? 'FREE — the $ above is the list-price VALUE of this compute (what it would cost in a private repo), not money owed' : 'list-price; included plan minutes are consumed before these amounts reach the invoice'}.${extra}`;
354
+ if (v === 'public') {
355
+ return note(billed.jobs > 0
356
+ ? ` NOTE: ${billed.jobs} macOS/larger-runner jobs ARE billed even for public repos (~$${billed.cost.toFixed(2)}).`
357
+ : '');
358
+ }
359
+ if (v === 'private') return note('');
360
+ if (v === 'mixed' && rep.provenance.orgVisibility) {
361
+ const o = rep.provenance.orgVisibility;
362
+ const billed = o.billedEvenIfPublic || { jobs: 0, cost: 0 };
363
+ return `>>> org of mixed repos: ${o.public} public (standard Linux/Windows minutes FREE there) / ${o.private} private (list-price; included plan minutes first) / ${o.unknown} unknown.` +
364
+ (billed.jobs > 0 ? ` macOS/larger-runner jobs ARE billed even on public repos (~$${billed.cost.toFixed(2)} in this sample).` : '') +
365
+ ' The $ above is list-price VALUE of compute, not money owed.';
366
+ }
367
+ return null; // unknown — stay silent rather than guess
368
+ }
369
+
370
+ function buildReport(repo, runs, jobsByRun, cfg, days, repoVis = 'unknown') {
371
+ const totals = Object.fromEntries(CLASS_ORDER.map(k => [k, { runs: 0, jobs: 0, minutes: 0, cost: 0 }]));
372
+ const selfHosted = { jobs: 0, minutes: 0 }; // $0 while the platform fee is postponed
373
+ let inProgressJobs = 0, rateFallbackJobs = 0, countedJobs = 0, totalMinutes = 0, totalCost = 0;
374
+ let skippedJobs = 0, skippedCost = 0, zeroDurationJobs = 0, zeroDurationCost = 0;
375
+ const byWorkflow = new Map(); // workflow name -> {class -> {cost, minutes}} (agent view)
376
+ const byActor = new Map();
377
+ const evidence = []; // one row per billable job — the audit trail behind every aggregate
378
+ const jobsFetched = runs.reduce((s, r) => s + (jobsByRun.get(r.id) || []).length, 0);
379
+
380
+ for (const run of runs) {
381
+ const { login, klass } = classifyActor(run, cfg);
382
+ const src = run.triggering_actor?.login ? run.triggering_actor : run.actor;
383
+ const srcType = src?.type || '';
384
+ const jobs = jobsByRun.get(run.id) || [];
385
+ let runMinutes = 0, runCost = 0, anyJobCounted = false;
386
+ for (const job of jobs) {
387
+ const mins = jobMinutes(job);
388
+ if (!job.started_at) continue;
389
+ if (!job.completed_at) { inProgressJobs++; continue; }
390
+ const { sku, rate, fallback } = inferRate(job, cfg);
391
+ if (fallback) rateFallbackJobs++;
392
+ if (sku === 'self_hosted') { selfHosted.jobs++; selfHosted.minutes += mins; }
393
+ const cost = mins * rate;
394
+ runMinutes += mins; runCost += cost; countedJobs++; anyJobCounted = true;
395
+ // Jobs with runner timestamps but conclusion=skipped or zero/negative duration
396
+ // are billed at the 1-min minimum. Whether GitHub bills them identically is
397
+ // unverifiable without invoice access — so we flag them with exact totals
398
+ // instead of silently deciding either way.
399
+ if (job.conclusion === 'skipped') { skippedJobs++; skippedCost += cost; }
400
+ if (new Date(job.completed_at) <= new Date(job.started_at)) { zeroDurationJobs++; zeroDurationCost += cost; }
401
+ evidence.push({
402
+ run_id: run.id, run_number: run.run_number ?? '', workflow: run.name || '(unnamed)',
403
+ event: run.event || '', actor_login: login, actor_type: srcType, class: klass,
404
+ job_id: job.id, job_name: job.name || '', started_at: job.started_at, completed_at: job.completed_at,
405
+ minutes: mins, sku, rate_usd_per_min: rate, cost_usd: cost,
406
+ runner_labels: (job.labels || []).join('|'),
407
+ rate_fallback: fallback, conclusion: job.conclusion ?? '',
408
+ });
409
+ totalMinutes += mins; totalCost += cost;
410
+ const wf = run.name || '(unnamed workflow)';
411
+ if (!byWorkflow.has(wf)) byWorkflow.set(wf, { cost: 0, minutes: 0, runs: new Set() });
412
+ byWorkflow.get(wf).cost += cost;
413
+ byWorkflow.get(wf).minutes += mins;
414
+ byWorkflow.get(wf).runs.add(run.id);
415
+ }
416
+ totals[klass].runs += 1;
417
+ if (anyJobCounted) {
418
+ totals[klass].jobs += jobs.filter(j => j.started_at && j.completed_at).length;
419
+ totals[klass].minutes += runMinutes;
420
+ totals[klass].cost += runCost;
421
+ }
422
+ if (login) {
423
+ if (!byActor.has(login)) byActor.set(login, { login, klass, runs: 0, minutes: 0, cost: 0 });
424
+ const a = byActor.get(login);
425
+ a.runs += 1; a.minutes += runMinutes; a.cost += runCost;
426
+ }
427
+ }
428
+
429
+ const pct = (x, of) => (of > 0 ? (100 * x) / of : 0);
430
+ for (const k of CLASS_ORDER) {
431
+ totals[k].pctCost = pct(totals[k].cost, totalCost);
432
+ totals[k].pctRuns = pct(totals[k].runs, runs.length);
433
+ }
434
+
435
+ const topWorkflowsByCost = [...byWorkflow.entries()]
436
+ .map(([name, v]) => ({ workflow: name, cost: v.cost, minutes: v.minutes, runs: v.runs.size, pctOfTotal: pct(v.cost, totalCost) }))
437
+ .sort((a, b) => b.cost - a.cost)
438
+ .slice(0, 3);
439
+
440
+ const topActors = [...byActor.values()].sort((a, b) => b.cost - a.cost).slice(0, 8);
441
+
442
+ return {
443
+ repo,
444
+ window: { days, from: runs.length ? runs[runs.length - 1].created_at : null, to: runs.length ? runs[0].created_at : null },
445
+ generatedAt: new Date().toISOString(),
446
+ pricingModel: 'github-hosted list prices effective 2026-01-01 (see methodology in README)',
447
+ totals,
448
+ topWorkflowsByCost,
449
+ topActors,
450
+ runsAnalyzed: runs.length,
451
+ jobsCounted: countedJobs,
452
+ totalMinutes,
453
+ totalCost,
454
+ selfHosted,
455
+ inProgressJobs,
456
+ rateFallbackJobs,
457
+ skippedJobs, skippedCost, zeroDurationJobs, zeroDurationCost,
458
+ evidence,
459
+ provenance: {
460
+ repoVisibility: repoVis,
461
+ billedEvenIfPublic: (() => {
462
+ let jobs = 0, cost = 0;
463
+ for (const e of evidence) {
464
+ if (e.sku === 'actions_macos' || e.sku === 'macos_l' || e.sku === 'macos_xl' ||
465
+ e.sku.endsWith('_gpu') || /\d+_core$/.test(e.sku)) { jobs++; cost += e.cost_usd; }
466
+ }
467
+ return { jobs, cost };
468
+ })(),
469
+ dataSources: [
470
+ `GET /repos/${repo}/actions/runs (workflow-run metadata)`,
471
+ 'GET /repos/{repo}/actions/runs/{id}/jobs (job metadata, latest attempt per job)',
472
+ 'GET /repos/{repo} (repository visibility: public/private)',
473
+ ],
474
+ neverAccessed: 'repository code, logs, secrets — workflow metadata only',
475
+ attribution: 'run.triggering_actor (fallback run.actor) -> agent | human | bot | unattributed',
476
+ skippedPolicy: 'jobs holding runner timestamps (incl. conclusion=skipped / zero duration) are billed at the 1-min minimum and reported separately — subtract them if your invoice proves GitHub does not bill such executions',
477
+ rates: 'GitHub-hosted list prices effective 2026-01-01 — docs.github.com/en/billing/reference/actions-runner-pricing',
478
+ ratesVersion: '2026-01-01',
479
+ selfHostedPolicy: '$0/min while the $0.002 platform fee is postponed; minutes still counted',
480
+ jobsFetched,
481
+ inProgressExcluded: inProgressJobs,
482
+ deterministic: 'same repo + window -> same numbers',
483
+ },
484
+ };
485
+ }
486
+
487
+ // Org mode: aggregate per-repo reports into one org-level report. Windows vary
488
+ // per repo (--max-runs cap per repo) — stated in provenance, never averaged away.
489
+ function buildOrgReport(org, reports) {
490
+ const totals = Object.fromEntries(CLASS_ORDER.map(k => [k, { runs: 0, jobs: 0, minutes: 0, cost: 0 }]));
491
+ const selfHosted = { jobs: 0, minutes: 0 };
492
+ let totalMinutes = 0, totalCost = 0, runsAnalyzed = 0, jobsCounted = 0, inProgressJobs = 0, jobsFetched = 0;
493
+ let rateFallbackJobs = 0, skippedJobs = 0, skippedCost = 0, zeroDurationJobs = 0, zeroDurationCost = 0;
494
+ const actors = new Map();
495
+ const vis = { public: 0, private: 0, unknown: 0 };
496
+ const billed = { jobs: 0, cost: 0 };
497
+ for (const r of reports) {
498
+ for (const k of CLASS_ORDER) {
499
+ totals[k].runs += r.totals[k].runs; totals[k].jobs += r.totals[k].jobs;
500
+ totals[k].minutes += r.totals[k].minutes; totals[k].cost += r.totals[k].cost;
501
+ }
502
+ selfHosted.jobs += r.selfHosted.jobs; selfHosted.minutes += r.selfHosted.minutes;
503
+ totalMinutes += r.totalMinutes; totalCost += r.totalCost;
504
+ runsAnalyzed += r.runsAnalyzed; jobsCounted += r.jobsCounted; inProgressJobs += r.inProgressJobs;
505
+ jobsFetched += r.provenance.jobsFetched || 0;
506
+ rateFallbackJobs += r.rateFallbackJobs;
507
+ skippedJobs += r.skippedJobs; skippedCost += r.skippedCost;
508
+ zeroDurationJobs += r.zeroDurationJobs; zeroDurationCost += r.zeroDurationCost;
509
+ const rv = r.provenance.repoVisibility;
510
+ if (rv === 'public' || rv === 'private') vis[rv]++; else vis.unknown++;
511
+ const b = r.provenance.billedEvenIfPublic || { jobs: 0, cost: 0 };
512
+ billed.jobs += b.jobs; billed.cost += b.cost;
513
+ // topActors are per-repo top-8 — the merged view is approximate for large orgs
514
+ for (const a of r.topActors) {
515
+ if (!actors.has(a.login)) actors.set(a.login, { ...a });
516
+ else { const x = actors.get(a.login); x.runs += a.runs; x.minutes += a.minutes; x.cost += a.cost; }
517
+ }
518
+ }
519
+ const pct = (x, of) => (of > 0 ? (100 * x) / of : 0);
520
+ for (const k of CLASS_ORDER) {
521
+ totals[k].pctCost = pct(totals[k].cost, totalCost);
522
+ totals[k].pctRuns = pct(totals[k].runs, runsAnalyzed);
523
+ }
524
+ const froms = reports.map(r => r.window.from).filter(Boolean).sort();
525
+ return {
526
+ repo: `org: ${org}`,
527
+ org: true,
528
+ reposAnalyzed: reports.length,
529
+ window: { days: reports[0]?.window.days, from: froms[0] ?? null, to: froms[froms.length - 1] ?? null },
530
+ generatedAt: new Date().toISOString(),
531
+ pricingModel: reports[0]?.pricingModel,
532
+ totals,
533
+ topReposByCost: [...reports].sort((a, b) => b.totalCost - a.totalCost).slice(0, 5)
534
+ .map(r => ({ repo: r.repo, runs: r.runsAnalyzed, minutes: Math.round(r.totalMinutes), cost: r.totalCost, agentPct: r.totals.agent.pctCost })),
535
+ topActors: [...actors.values()].sort((a, b) => b.cost - a.cost).slice(0, 8),
536
+ runsAnalyzed, jobsCounted, totalMinutes, totalCost, selfHosted, inProgressJobs,
537
+ rateFallbackJobs, skippedJobs, skippedCost, zeroDurationJobs, zeroDurationCost,
538
+ evidence: [], // per-repo evidence lives in each repo report (org report is the aggregate)
539
+ provenance: {
540
+ repoVisibility: 'mixed',
541
+ orgVisibility: { ...vis, billedEvenIfPublic: billed },
542
+ jobsFetched,
543
+ inProgressExcluded: inProgressJobs,
544
+ dataSources: [
545
+ 'GET /orgs/{org}/repos (or /users/{login}/repos) — repo list sorted by last push',
546
+ 'GET /repos/{repo}/actions/runs (workflow-run metadata)',
547
+ 'GET /repos/{repo}/actions/runs/{id}/jobs (job metadata, latest attempt per job)',
548
+ 'GET /repos/{repo} (repository visibility: public/private)',
549
+ ],
550
+ neverAccessed: 'repository code, logs, secrets — workflow metadata only',
551
+ attribution: 'run.triggering_actor (fallback run.actor) -> agent | human | bot | unattributed (+ agent-assisted via Co-Authored-By when enabled)',
552
+ rates: 'GitHub-hosted list prices effective 2026-01-01 — docs.github.com/en/billing/reference/actions-runner-pricing',
553
+ ratesVersion: '2026-01-01',
554
+ selfHostedPolicy: '$0/min while the $0.002 platform fee is postponed; minutes still counted',
555
+ orgMode: `per-repo windows vary — ${reports.length} repos, each capped at its --max-runs most recent runs`,
556
+ skippedPolicy: 'jobs holding runner timestamps (incl. conclusion=skipped / zero duration) are billed at the 1-min minimum and reported separately — subtract them if your invoice proves GitHub does not bill such executions',
557
+ deterministic: 'same repos + windows -> same numbers',
558
+ },
559
+ };
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Rendering
564
+ // ---------------------------------------------------------------------------
565
+ const money = n => (n < 0 ? '-$' : '$') + Math.abs(n).toFixed(2);
566
+ const CLASS_PAD = Math.max(...CLASS_ORDER.map(k => k.length));
567
+
568
+ function renderTable(rep) {
569
+ const L = [];
570
+ L.push(`\ncostgrep — ${rep.repo} (last ${rep.window.days} days, ${rep.runsAnalyzed} runs, ${rep.jobsCounted} jobs)`);
571
+ L.push('='.repeat(78));
572
+ L.push(`${'class'.padEnd(CLASS_PAD)} runs run% minutes cost cost%`);
573
+ L.push('-'.repeat(78));
574
+ for (const k of CLASS_ORDER) {
575
+ const t = rep.totals[k];
576
+ L.push(
577
+ `${k.padEnd(CLASS_PAD)} ${String(t.runs).padStart(5)} ${t.pctRuns.toFixed(1).padStart(5)}%` +
578
+ ` ${String(Math.round(t.minutes)).padStart(9)} ${money(t.cost).padStart(10)} ${t.pctCost.toFixed(1).padStart(6)}%`
579
+ );
580
+ }
581
+ L.push('-'.repeat(78));
582
+ L.push(`${'total'.padEnd(CLASS_PAD)} ${String(rep.runsAnalyzed).padStart(5)} ${String(Math.round(rep.totalMinutes)).padStart(9)} ${money(rep.totalCost).padStart(10)}`);
583
+ if (rep.selfHosted.jobs > 0) L.push(`(plus ${rep.selfHosted.jobs} self-hosted jobs, ${Math.round(rep.selfHosted.minutes)} min — $0 while the platform fee is postponed)`);
584
+ L.push('');
585
+ const a = rep.totals.agent;
586
+ L.push(`>>> Agents cost ${money(a.cost)} (${a.pctCost.toFixed(1)}% of CI spend, ${a.runs} runs / ${a.pctRuns.toFixed(1)}%).`);
587
+ L.push(`>>> Bot runs: ${rep.totals.bot.runs} (${rep.totals.bot.pctRuns.toFixed(1)}% of all runs), bot spend ${money(rep.totals.bot.cost)}.`);
588
+ if (rep.topWorkflowsByCost?.length) {
589
+ L.push('>>> Top workflows by total cost:');
590
+ for (const w of rep.topWorkflowsByCost) L.push(` ${money(w.cost).padStart(10)} ${w.pctOfTotal.toFixed(1).padStart(5)}% ${w.workflow}`);
591
+ }
592
+ if (rep.topReposByCost?.length) {
593
+ L.push('>>> Top repos by total cost (agent% of that repo):');
594
+ for (const r of rep.topReposByCost) L.push(` ${money(r.cost).padStart(10)} ${r.agentPct.toFixed(1).padStart(5)}% ${r.repo}`);
595
+ }
596
+ if (rep.rateFallbackJobs > 0) L.push(`>>> honesty note: ${rep.rateFallbackJobs} jobs had unrecognized runner labels — priced at the standard Linux rate.`);
597
+ const q = rep.skippedJobs + rep.zeroDurationJobs;
598
+ if (q > 0) L.push(`>>> honesty note: ${rep.skippedJobs} skipped and ${rep.zeroDurationJobs} zero-duration jobs billed at the 1-min minimum ($${(rep.skippedCost + rep.zeroDurationCost).toFixed(3)} included; subtract if your invoice proves GitHub doesn't bill them).`);
599
+ const vn = visibilityNotice(rep);
600
+ if (vn) L.push(vn);
601
+ L.push('>>> List-price model: NOT your invoice. Included plan minutes are consumed first; hosted phase reconciles against the billing API.');
602
+ return L.join('\n');
603
+ }
604
+
605
+ function renderMarkdown(rep) {
606
+ const a = rep.totals.agent;
607
+ const rows = CLASS_ORDER.map(k => {
608
+ const t = rep.totals[k];
609
+ return `| ${k} | ${t.runs} | ${t.pctRuns.toFixed(1)}% | ${Math.round(t.minutes)} | ${money(t.cost)} | ${t.pctCost.toFixed(1)}% |`;
610
+ }).join('\n');
611
+ const wf = (rep.topWorkflowsByCost || []).map(w => `| ${w.workflow} | ${money(w.cost)} | ${w.pctOfTotal.toFixed(1)}% |`).join('\n');
612
+ const repos = (rep.topReposByCost || []).map(r => `| ${r.repo} | ${money(r.cost)} | ${r.agentPct.toFixed(1)}% |`).join('\n');
613
+ const p = rep.provenance;
614
+ return `## 🤖 costgrep — ${rep.repo}
615
+
616
+ **${money(a.cost)} from AI agents (${a.pctCost.toFixed(1)}% of CI spend)** · bot runs: ${rep.totals.bot.runs} (${rep.totals.bot.pctRuns.toFixed(1)}%)
617
+
618
+ | class | runs | run% | minutes | cost | cost% |
619
+ |---|---|---|---|---|---|
620
+ ${rows}
621
+
622
+ ${wf ? `Top workflows by cost:\n\n| workflow | cost | % |\n|---|---|---|\n${wf}\n` : ''}${repos ? `\nTop repos by cost (agent% of repo):\n\n| repo | cost | agent% |\n|---|---|---|\n${repos}` : ''}
623
+
624
+ ### Where these numbers come from
625
+
626
+ - **Repo visibility:** ${p.repoVisibility === 'mixed' && p.orgVisibility
627
+ ? `mixed — ${p.orgVisibility.public} public / ${p.orgVisibility.private} private / ${p.orgVisibility.unknown} unknown repos (per-repo detail in each repo report); macOS/larger-runner jobs are billed even on public repos${p.orgVisibility.billedEvenIfPublic.jobs > 0 ? ` (~$${p.orgVisibility.billedEvenIfPublic.cost.toFixed(2)} in this sample)` : ''}; the $ figures are list-price VALUE, not money owed`
628
+ : p.repoVisibility === 'public' ? `public — standard Linux/Windows hosted minutes are **free** for public repositories; the $ figures are the list-price value of this compute, not money owed${p.billedEvenIfPublic.jobs > 0 ? `. Exception: ${p.billedEvenIfPublic.jobs} macOS/larger-runner jobs are billed even for public repos (~$${p.billedEvenIfPublic.cost.toFixed(2)}).` : '.'}`
629
+ : p.repoVisibility === 'private' ? 'private — list-price model; included plan minutes are consumed before these amounts reach the invoice.'
630
+ : ' (could not determine).'}
631
+ - **Data:** ${p.dataSources[0]}; ${p.dataSources[1]}. ${p.neverAccessed}.
632
+ - **Window:** last ${rep.window.days} days (${rep.window.from ?? '—'} → ${rep.window.to ?? '—'}), ${rep.runsAnalyzed} runs, ${rep.jobsCounted} of ${p.jobsFetched} fetched jobs counted (${p.inProgressExcluded} in-progress jobs excluded).
633
+ - **Attribution:** ${p.attribution}.
634
+ - **Rates:** ${p.rates}. Self-hosted: ${p.selfHostedPolicy}.
635
+ - **Honesty flags:** ${rep.rateFallbackJobs} jobs on the rate fallback (unknown runner labels → standard Linux rate); skipped/zero-duration jobs billed at the 1-min minimum: ${rep.skippedJobs + rep.zeroDurationJobs} ($${(rep.skippedCost + rep.zeroDurationCost).toFixed(3)} included — subtract if your invoice differs); unattributed: ${rep.totals.unattributed.runs} runs.
636
+
637
+ _List-price model — NOT the invoice: included plan minutes are consumed first. Deterministic: ${p.deterministic}. Verify any number against the per-job \`evidence[]\` in the JSON / CSV export. Full methodology: README._
638
+ `;
639
+ }
640
+
641
+ function csvEscape(v) {
642
+ const s = String(v ?? '');
643
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
644
+ }
645
+
646
+ // Per-job audit trail: open it in any spreadsheet and recompute minutes x rate —
647
+ // the sums must equal the headline totals.
648
+ function renderCsv(rep) {
649
+ const cols = ['run_id', 'run_number', 'workflow', 'event', 'actor_login', 'actor_type', 'class',
650
+ 'job_id', 'job_name', 'started_at', 'completed_at', 'minutes', 'sku', 'rate_usd_per_min', 'cost_usd',
651
+ 'runner_labels', 'rate_fallback', 'conclusion'];
652
+ const lines = [cols.join(',')];
653
+ for (const e of rep.evidence) lines.push(cols.map(c => csvEscape(e[c])).join(','));
654
+ return lines.join('\n') + '\n';
655
+ }
656
+
657
+ function outputsFor(rep) {
658
+ return {
659
+ 'total-cost': rep.totalCost.toFixed(4),
660
+ 'total-minutes': String(Math.round(rep.totalMinutes)),
661
+ 'agent-cost': rep.totals.agent.cost.toFixed(4),
662
+ 'agent-share-pct': rep.totals.agent.pctCost.toFixed(1),
663
+ 'agent-assisted-cost': (rep.totals['agent-assisted']?.cost ?? 0).toFixed(4),
664
+ 'agent-runs': String(rep.totals.agent.runs),
665
+ 'bot-runs': String(rep.totals.bot.runs),
666
+ 'bot-runs-pct': rep.totals.bot.pctRuns.toFixed(1),
667
+ };
668
+ }
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // Reconciliation & AI credits (experimental — org billing endpoints)
672
+ // ---------------------------------------------------------------------------
673
+ // These subcommands read /organizations/{org}/settings/billing/* — the only
674
+ // places in costgrep that require an org token with billing rights. They are
675
+ // invoked explicitly (reconcile | credits) and everything they print carries
676
+ // the experimental label: this code path is not yet validated against a live
677
+ // billing account, so deltas are hypotheses until calibrated on an invoice.
678
+
679
+ const EXPERIMENTAL = 'EXPERIMENTAL: not yet validated against a live billing account — treat deltas as hypotheses until calibrated (methodology in README).';
680
+
681
+ function monthBounds(year, month) {
682
+ const from = new Date(Date.UTC(year, month - 1, 1));
683
+ const to = new Date(Date.UTC(year, month, 1)); // month is 1-based -> this is the 1st of the next month
684
+ return { from, to };
685
+ }
686
+
687
+ async function fetchBillingUsage(org, year, month, token) {
688
+ const data = await gh(`/organizations/${org}/settings/billing/usage?year=${year}&month=${month}`, token);
689
+ return data.usageItems || [];
690
+ }
691
+
692
+ async function fetchAiCredits(org, year, month, token, user) {
693
+ const u = user ? `&user=${encodeURIComponent(user)}` : '';
694
+ const data = await gh(`/organizations/${org}/settings/billing/ai_credit/usage?year=${year}&month=${month}${u}`, token);
695
+ return data;
696
+ }
697
+
698
+ // Pure: billing items + our per-repo reports -> the reconciliation verdict.
699
+ // billingHasMore > 0 means money the invoice sees that we could not attribute.
700
+ function buildReconciliation(billingItems, repoReports, coverage) {
701
+ const actions = billingItems.filter(i => /actions/i.test(String(i.product || '')));
702
+ const billingTotal = actions.reduce((s, i) => s + (i.netAmount || 0), 0);
703
+ const billingBySku = new Map();
704
+ for (const i of actions) billingBySku.set(i.sku, (billingBySku.get(i.sku) || 0) + (i.netAmount || 0));
705
+ const billingRepos = new Set(actions.map(i => i.repositoryName).filter(Boolean));
706
+
707
+ const ourBySku = new Map();
708
+ let ourTotal = 0;
709
+ for (const rep of repoReports) {
710
+ ourTotal += rep.totalCost;
711
+ for (const e of rep.evidence) ourBySku.set(e.sku, (ourBySku.get(e.sku) || 0) + e.cost_usd);
712
+ }
713
+
714
+ const skus = [...new Set([...billingBySku.keys(), ...ourBySku.keys()])].sort();
715
+ const perSku = skus.map(sku => {
716
+ const b = billingBySku.get(sku) || 0, o = ourBySku.get(sku) || 0;
717
+ return { sku, billing: b, ours: o, delta: b - o };
718
+ });
719
+ const delta = billingTotal - ourTotal;
720
+ const deltaPct = billingTotal > 0 ? (100 * delta) / billingTotal : 0;
721
+
722
+ const warnings = [];
723
+ if (coverage.reposScanned < coverage.reposInBilling) {
724
+ warnings.push(`coverage: billing names ${coverage.reposInBilling} repositories with Actions usage; we scanned ${coverage.reposScanned} most-recently-pushed (--repos) — unscanned repos land in the delta`);
725
+ }
726
+ if (coverage.runsCapped) warnings.push('coverage: at least one repo hit the --max-runs cap — runs beyond the cap are invisible to our side of the comparison');
727
+ if (!billingItems.length) warnings.push('billing returned zero usage items — check the org, the month, and that the org is on the enhanced billing platform');
728
+
729
+ return {
730
+ billingTotal, ourTotal, delta, deltaPct, perSku,
731
+ unattributed: delta > 0 ? delta : 0,
732
+ overattributed: delta < 0 ? -delta : 0,
733
+ billingRepos: billingRepos.size, warnings,
734
+ experimental: true,
735
+ };
736
+ }
737
+
738
+ function renderReconciliation(rec, orgLabel, year, month) {
739
+ const L = [];
740
+ L.push(`\ncostgrep reconcile — ${orgLabel} ${year}-${String(month).padStart(2, '0')} (billing API vs our recomputation)`);
741
+ L.push('='.repeat(78));
742
+ L.push('SKU billing $ ours $ delta $');
743
+ L.push('-'.repeat(78));
744
+ for (const s of rec.perSku) {
745
+ L.push(`${String(s.sku).padEnd(26)} ${s.billing.toFixed(2).padStart(9)} ${s.ours.toFixed(2).padStart(11)} ${s.delta.toFixed(2).padStart(11)}`);
746
+ }
747
+ L.push('-'.repeat(78));
748
+ L.push(`${'TOTAL'.padEnd(26)} ${rec.billingTotal.toFixed(2).padStart(9)} ${rec.ourTotal.toFixed(2).padStart(11)} ${rec.delta.toFixed(2).padStart(11)} (${rec.deltaPct.toFixed(1)}%)`);
749
+ L.push('');
750
+ L.push(`>>> unattributed (billing sees it, we can't attribute): $${rec.unattributed.toFixed(2)}${rec.overattributed > 0 ? ` · we attribute MORE than billing: $${rec.overattributed.toFixed(2)} (check rate assumptions)` : ''}`);
751
+ for (const w of rec.warnings) L.push(`>>> ${w}`);
752
+ L.push(`>>> ${EXPERIMENTAL}`);
753
+ return L.join('\n');
754
+ }
755
+
756
+ function renderCredits(data, orgLabel, year, month) {
757
+ const items = data.usageItems || [];
758
+ const total = items.reduce((s, i) => s + (i.netAmount || 0), 0);
759
+ const byModel = new Map();
760
+ for (const i of items) byModel.set(i.model || '(unknown)', (byModel.get(i.model || '(unknown)') || 0) + (i.netAmount || 0));
761
+ const L = [];
762
+ L.push(`\ncostgrep credits — ${orgLabel} ${year}-${String(month).padStart(2, '0')} (AI credits from the billing API)`);
763
+ L.push('='.repeat(60));
764
+ for (const [model, cost] of [...byModel.entries()].sort((a, b) => b[1] - a[1])) {
765
+ L.push(`${String(model).padEnd(40)} $${cost.toFixed(2)}`);
766
+ }
767
+ L.push('-'.repeat(60));
768
+ L.push(`${'TOTAL AI CREDITS'.padEnd(40)} $${total.toFixed(2)}`);
769
+ L.push('');
770
+ L.push('>>> per-user breakdown is not exposed by the API — filter with --user <login>');
771
+ L.push(`>>> ${EXPERIMENTAL}`);
772
+ return L.join('\n');
773
+ }
774
+
775
+ // PR-scoped section for --pr-comment: everything whose head SHA == the PR head.
776
+ function prSection(rep, runsArr, headSha) {
777
+ if (!headSha || !runsArr) return '';
778
+ const prRunIds = new Set(runsArr.filter(r => r.head_sha === headSha).map(r => r.id));
779
+ const rows = rep.evidence.filter(e => prRunIds.has(e.run_id));
780
+ if (!rows.length) return '';
781
+ const cost = rows.reduce((s, e) => s + e.cost_usd, 0);
782
+ const mins = rows.reduce((s, e) => s + e.minutes, 0);
783
+ const byClass = {};
784
+ for (const e of rows) byClass[e.class] = (byClass[e.class] || 0) + e.cost_usd;
785
+ const split = Object.entries(byClass).sort((a, b) => b[1] - a[1])
786
+ .map(([k, v]) => `${k} $${v.toFixed(2)}`).join(' · ');
787
+ return `\n### This PR's CI so far\n\n**${money(cost)} · ${Math.round(mins)} minutes** across ${prRunIds.size} runs on head \`${headSha.slice(0, 10)}\` (${split})\n`;
788
+ }
789
+
790
+ async function postSlack(url, text) {
791
+ if (!/^https:\/\/hooks\.slack\.com\/services\//.test(url)) {
792
+ throw new Error('--slack-webhook: expected an https://hooks.slack.com/services/... URL');
793
+ }
794
+ const res = await fetch(url, {
795
+ method: 'POST',
796
+ headers: { 'Content-Type': 'application/json' },
797
+ body: JSON.stringify({ text: '```\n' + text.slice(0, 2900) + '\n```' }),
798
+ signal: AbortSignal.timeout(HTTP_TIMEOUT),
799
+ });
800
+ if (!res.ok) throw new Error(`Slack webhook responded ${res.status}`);
801
+ }
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // Main
805
+ // ---------------------------------------------------------------------------
806
+ function prevMonth(now = new Date()) {
807
+ const y = now.getUTCFullYear(), m = now.getUTCMonth() + 1;
808
+ return m === 1 ? { year: y - 1, month: 12 } : { year: y, month: m - 1 };
809
+ }
810
+
811
+ function parseSideArgs(kind) {
812
+ const { values } = parseArgs({
813
+ options: {
814
+ org: { type: 'string' },
815
+ month: { type: 'string' }, // YYYY-MM; default = previous full month
816
+ 'billing-token': { type: 'string' }, // org token with billing rights (falls back to --token / GITHUB_TOKEN)
817
+ token: { type: 'string' }, // for run metadata (actions:read)
818
+ repos: { type: 'string', default: '20' },
819
+ 'max-runs': { type: 'string' },
820
+ 'json-file': { type: 'string' },
821
+ user: { type: 'string' }, // credits: filter by user
822
+ quiet: { type: 'boolean', default: false },
823
+ help: { type: 'boolean', default: false },
824
+ },
825
+ args: process.argv.slice(3),
826
+ });
827
+ if (values.help) {
828
+ console.log(`usage: costgrep.mjs ${kind} --org NAME [--month YYYY-MM] [--billing-token T]\n [--token T] [--repos N] [--max-runs N] [--json-file F]${kind === 'credits' ? ' [--user LOGIN]' : ''}`);
829
+ process.exit(0);
830
+ }
831
+ let year, month;
832
+ if (values.month) {
833
+ const m = values.month.match(/^(\d{4})-(\d{2})$/);
834
+ if (!m || +m[2] < 1 || +m[2] > 12) throw new Error(`invalid --month "${values.month}" — expected YYYY-MM`);
835
+ year = +m[1]; month = +m[2];
836
+ } else ({ year, month } = prevMonth());
837
+ values.year = year; values.month = month;
838
+ values.repos = Math.max(1, parseInt(values.repos, 10) || 20);
839
+ values['max-runs'] = parseInt(values['max-runs'] ?? '', 10) || 100;
840
+ if (values.org && !/^[A-Za-z0-9-]+$/.test(values.org)) throw new Error(`invalid --org "${values.org}"`);
841
+ return values;
842
+ }
843
+
844
+ async function orgReportsForWindow(org, from, to, cfg, token, reposLimit, maxRuns, quiet) {
845
+ const repos = await listOrgRepos(org, token, reposLimit);
846
+ const reports = [];
847
+ let runsCapped = false;
848
+ for (let i = 0; i < repos.length; i++) {
849
+ const r = repos[i];
850
+ const vis = await repoVisibility(r, token);
851
+ const rr = await listRuns(r, from, token, maxRuns, to);
852
+ if (rr.length >= maxRuns) runsCapped = true;
853
+ if (!rr.length) continue;
854
+ const jm = new Map();
855
+ for (const run of rr) jm.set(run.id, await listJobs(r, run.id, token));
856
+ const one = buildReport(r, rr, jm, cfg, Math.max(1, Math.round((to - from) / 86_400_000)), vis);
857
+ reports.push(one);
858
+ if (!quiet) console.error(` [${i + 1}/${repos.length}] ${r}: ${rr.length} runs, $${one.totalCost.toFixed(2)}`);
859
+ }
860
+ return { repos, reports, runsCapped };
861
+ }
862
+
863
+ async function reconcileMain() {
864
+ const a = parseSideArgs('reconcile');
865
+ if (!a.org) throw new Error('reconcile: --org NAME required');
866
+ const cfg = loadConfig(null);
867
+ const runsToken = a.token || process.env.GITHUB_TOKEN;
868
+ const billingToken = a['billing-token'] || a.token || process.env.GITHUB_TOKEN;
869
+ const { from, to } = monthBounds(a.year, a.month);
870
+ const items = await fetchBillingUsage(a.org, a.year, a.month, billingToken);
871
+ const { repos, reports, runsCapped } = await orgReportsForWindow(a.org, from, to, cfg, runsToken, a.repos, a['max-runs'], a.quiet);
872
+ const billingRepos = new Set(items.filter(i => /actions/i.test(String(i.product || ''))).map(i => i.repositoryName).filter(Boolean)).size;
873
+ const rec = buildReconciliation(items, reports, { reposScanned: repos.length, reposInBilling: billingRepos, runsCapped });
874
+ console.log(renderReconciliation(rec, `org: ${a.org}`, a.year, a.month));
875
+ if (a['json-file']) writeFileSync(a['json-file'], JSON.stringify(rec, null, 2));
876
+ }
877
+
878
+ async function creditsMain() {
879
+ const a = parseSideArgs('credits');
880
+ if (!a.org) throw new Error('credits: --org NAME required');
881
+ const billingToken = a['billing-token'] || a.token || process.env.GITHUB_TOKEN;
882
+ let data;
883
+ try {
884
+ data = await fetchAiCredits(a.org, a.year, a.month, billingToken, a.user);
885
+ } catch (e) {
886
+ if (String(e.message).includes(' 404 ')) throw new Error(`no AI credit usage found for ${a.org} ${a.year}-${a.month} (404) — check the org, month, and that the org has AI credit billing`);
887
+ throw e;
888
+ }
889
+ console.log(renderCredits(data, `org: ${a.org}`, a.year, a.month));
890
+ if (a['json-file']) writeFileSync(a['json-file'], JSON.stringify(data, null, 2));
891
+ }
892
+
893
+ async function main() {
894
+ const sub = process.argv[2] && !process.argv[2].startsWith('-') ? process.argv[2] : null;
895
+ if (sub === 'reconcile') return reconcileMain();
896
+ if (sub === 'credits') return creditsMain();
897
+ if (sub) throw new Error(`unknown subcommand "${sub}" — use: reconcile | credits, or nothing for a report`);
898
+ const args = parseCli();
899
+ const cfg = loadConfig(args.config);
900
+ if (args['no-coab']) cfg.coab = false;
901
+ const since = new Date(Date.now() - args.days * 86_400_000);
902
+ const token = args.token || process.env.GITHUB_TOKEN;
903
+
904
+ let rep;
905
+ let runsArr = null;
906
+ if (args['fixture-dir']) {
907
+ const dir = args['fixture-dir'];
908
+ let runs = JSON.parse(readFileSync(`${dir}/runs.json`, 'utf8'));
909
+ const jobsByRun = new Map(runs.map(r => [r.id, r.jobs || []]));
910
+ runs = runs.filter(r => !r.created_at || new Date(r.created_at) >= since).slice(0, args['max-runs']);
911
+ runsArr = runs;
912
+ rep = buildReport(args.repo || '(fixture)', runs, jobsByRun, cfg, args.days);
913
+ } else if (args.org) {
914
+ if (args['pr-comment']) throw new Error('--pr-comment works with --repo, not --org');
915
+ const repos = await listOrgRepos(args.org, token, args.repos);
916
+ if (!repos.length) throw new Error(`no repositories visible for ${args.org} — check the org/user name and token scope`);
917
+ const reports = [];
918
+ for (let i = 0; i < repos.length; i++) {
919
+ const r = repos[i];
920
+ const vis = await repoVisibility(r, token);
921
+ const rr = await listRuns(r, since, token, args['max-runs']);
922
+ if (!rr.length) continue;
923
+ const jm = new Map();
924
+ for (const run of rr) jm.set(run.id, await listJobs(r, run.id, token));
925
+ const one = buildReport(r, rr, jm, cfg, args.days, vis);
926
+ reports.push(one);
927
+ if (!args.quiet) console.error(` [${i + 1}/${repos.length}] ${r}: ${rr.length} runs, $${one.totalCost.toFixed(2)}`);
928
+ }
929
+ if (!reports.length) throw new Error(`no workflow runs in the last ${args.days} days across ${repos.length} most-recently-pushed repos of ${args.org}`);
930
+ rep = buildOrgReport(args.org, reports);
931
+ } else {
932
+ if (!args.repo) throw new Error('--repo owner/name (or --org NAME, or GITHUB_REPOSITORY) required');
933
+ const vis = await repoVisibility(args.repo, token);
934
+ const runs = await listRuns(args.repo, since, token, args['max-runs']);
935
+ runsArr = runs;
936
+ const jobsByRun = new Map();
937
+ let done = 0;
938
+ for (const run of runs) {
939
+ jobsByRun.set(run.id, await listJobs(args.repo, run.id, token));
940
+ if (!args.quiet && ++done % 50 === 0) console.error(` ...fetched jobs for ${done}/${runs.length} runs`);
941
+ }
942
+ rep = buildReport(args.repo, runs, jobsByRun, cfg, args.days, vis);
943
+ }
944
+
945
+ console.log(renderTable(rep));
946
+
947
+ if (args['pr-comment']) {
948
+ const n = args['pr-comment'] === 'auto'
949
+ ? (process.env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\//) || [])[1]
950
+ : args['pr-comment'];
951
+ if (!/^\d+$/.test(String(n))) {
952
+ throw new Error(`--pr-comment: expected a PR number or "auto" (got "${args['pr-comment']}"; GITHUB_REF=${process.env.GITHUB_REF || 'unset'})`);
953
+ }
954
+ const pr = await gh(`/repos/${args.repo}/pulls/${n}`, token);
955
+ const body = renderMarkdown(rep) + prSection(rep, runsArr, pr.head?.sha) + '\n<!-- costgrep report -->';
956
+ const posted = await ghPost(`/repos/${args.repo}/issues/${n}/comments`, token, { body });
957
+ console.error(`report posted to PR #${n}: ${posted.html_url}`);
958
+ }
959
+
960
+ if (args['slack-webhook']) {
961
+ await postSlack(args['slack-webhook'], renderTable(rep));
962
+ console.error('report posted to Slack');
963
+ }
964
+
965
+ if (args['json-file']) {
966
+ writeFileSync(args['json-file'], JSON.stringify(rep, null, 2));
967
+ console.error(`full report written to ${args['json-file']}`);
968
+ }
969
+ if (args['md-file']) {
970
+ writeFileSync(args['md-file'], renderMarkdown(rep) + '\n');
971
+ console.error(`markdown report written to ${args['md-file']}`);
972
+ }
973
+ if (args['csv-file']) {
974
+ writeFileSync(args['csv-file'], renderCsv(rep));
975
+ console.error(`job-level evidence CSV written to ${args['csv-file']}`);
976
+ }
977
+ if (args['gh-output']) {
978
+ appendFileSync(args['gh-output'], Object.entries(outputsFor(rep)).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
979
+ }
980
+ if (args['step-summary'] && process.env.GITHUB_STEP_SUMMARY) {
981
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, renderMarkdown(rep) + '\n');
982
+ }
983
+ return rep;
984
+ }
985
+
986
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/').split('/').pop());
987
+ if (invokedDirectly) {
988
+ // exitCode (not process.exit): exiting while fetch sockets are still alive can
989
+ // trip libuv assertions on Windows; a natural drain is the honest, crash-free exit.
990
+ main().catch(e => { console.error(`error: ${e.message}`); process.exitCode = 1; });
991
+ }
992
+
993
+ export { classifyActor, jobMinutes, inferRate, buildReport, buildOrgReport, renderTable, renderMarkdown, renderCsv, outputsFor, visibilityNotice, coAuthoredByAgent, buildReconciliation, renderReconciliation, renderCredits, prSection, postSlack, monthBounds, prevMonth, DEFAULT_RATES, DEFAULT_AGENTS, DEFAULT_BOTS, DEFAULT_COAUTHORS, norm };