|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +const cp = require('node:child_process'); |
| 4 | + |
| 5 | +const TOOL_NAME = 'gx'; |
| 6 | + |
| 7 | +const DEFAULT_WARN_NET_USD = 1; // any paid spend at all |
| 8 | +const DEFAULT_CRITICAL_NET_USD = 10; // paid spend that has caused merge blocks before |
| 9 | + |
| 10 | +function runGh(args) { |
| 11 | + const result = cp.spawnSync('gh', args, { encoding: 'utf8' }); |
| 12 | + if (result.error) { |
| 13 | + const err = new Error(`gh binary not found: ${result.error.message}`); |
| 14 | + err.code = 'GH_BIN_MISSING'; |
| 15 | + throw err; |
| 16 | + } |
| 17 | + return result; |
| 18 | +} |
| 19 | + |
| 20 | +function ghApi(endpoint) { |
| 21 | + const result = runGh(['api', endpoint]); |
| 22 | + if (result.status !== 0) { |
| 23 | + const message = (result.stderr || result.stdout || '').trim(); |
| 24 | + if (/404/.test(message)) { |
| 25 | + const err = new Error(`GitHub API 404: ${endpoint}`); |
| 26 | + err.code = 'GH_API_NOT_FOUND'; |
| 27 | + throw err; |
| 28 | + } |
| 29 | + if (/403/.test(message)) { |
| 30 | + const err = new Error( |
| 31 | + `GitHub API 403: ${endpoint}. The current token lacks the billing scope (org owners need admin:org; user accounts need user scope).`, |
| 32 | + ); |
| 33 | + err.code = 'GH_API_FORBIDDEN'; |
| 34 | + throw err; |
| 35 | + } |
| 36 | + if (/410/.test(message)) { |
| 37 | + const err = new Error( |
| 38 | + `GitHub API 410: ${endpoint}. This endpoint was retired in early 2026; the new enhanced billing endpoint is /{scope}/{name}/settings/billing/usage.`, |
| 39 | + ); |
| 40 | + err.code = 'GH_API_GONE'; |
| 41 | + throw err; |
| 42 | + } |
| 43 | + throw new Error(`gh api ${endpoint} failed: ${message}`); |
| 44 | + } |
| 45 | + try { |
| 46 | + return JSON.parse(result.stdout); |
| 47 | + } catch (parseErr) { |
| 48 | + throw new Error(`gh api ${endpoint} returned non-JSON output: ${parseErr.message}`); |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +function detectCurrentLogin() { |
| 53 | + const result = runGh(['api', 'user', '--jq', '.login']); |
| 54 | + if (result.status !== 0) return null; |
| 55 | + return result.stdout.trim() || null; |
| 56 | +} |
| 57 | + |
| 58 | +function fetchUsage({ org, user } = {}) { |
| 59 | + if (org) { |
| 60 | + const usage = ghApi(`/orgs/${org}/settings/billing/usage`); |
| 61 | + return { scope: 'org', name: org, usage }; |
| 62 | + } |
| 63 | + if (user) { |
| 64 | + const usage = ghApi(`/users/${user}/settings/billing/usage`); |
| 65 | + return { scope: 'user', name: user, usage }; |
| 66 | + } |
| 67 | + const login = detectCurrentLogin(); |
| 68 | + if (!login) { |
| 69 | + throw new Error( |
| 70 | + `Could not detect the authenticated login. Pass --org <name> or --user <name> explicitly.`, |
| 71 | + ); |
| 72 | + } |
| 73 | + try { |
| 74 | + const usage = ghApi(`/users/${login}/settings/billing/usage`); |
| 75 | + return { scope: 'user', name: login, usage }; |
| 76 | + } catch (err) { |
| 77 | + if (err.code === 'GH_API_NOT_FOUND') { |
| 78 | + const usage = ghApi(`/orgs/${login}/settings/billing/usage`); |
| 79 | + return { scope: 'org', name: login, usage }; |
| 80 | + } |
| 81 | + throw err; |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +function currentMonthKey(now = new Date()) { |
| 86 | + const year = now.getUTCFullYear(); |
| 87 | + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); |
| 88 | + return `${year}-${month}`; |
| 89 | +} |
| 90 | + |
| 91 | +function itemMonthKey(item) { |
| 92 | + // Dates land as 'YYYY-MM-01T00:00:00Z' for the start of a billed month. |
| 93 | + return typeof item.date === 'string' ? item.date.slice(0, 7) : ''; |
| 94 | +} |
| 95 | + |
| 96 | +function thresholdSeverity(netUsd, warnUsd, criticalUsd) { |
| 97 | + if (netUsd >= criticalUsd) return 'critical'; |
| 98 | + if (netUsd >= warnUsd) return 'warn'; |
| 99 | + return 'ok'; |
| 100 | +} |
| 101 | + |
| 102 | +function shapeBudgetReport({ scope, name, usage, monthKey, warnUsd, criticalUsd }) { |
| 103 | + const items = Array.isArray(usage?.usageItems) ? usage.usageItems : []; |
| 104 | + const targetMonth = monthKey ?? currentMonthKey(); |
| 105 | + |
| 106 | + const actionsThisMonth = items.filter( |
| 107 | + (item) => |
| 108 | + item.product === 'actions' && |
| 109 | + item.unitType === 'Minutes' && |
| 110 | + itemMonthKey(item) === targetMonth, |
| 111 | + ); |
| 112 | + |
| 113 | + const totalMinutes = actionsThisMonth.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0); |
| 114 | + const totalGross = actionsThisMonth.reduce( |
| 115 | + (sum, item) => sum + (Number(item.grossAmount) || 0), |
| 116 | + 0, |
| 117 | + ); |
| 118 | + const totalDiscount = actionsThisMonth.reduce( |
| 119 | + (sum, item) => sum + (Number(item.discountAmount) || 0), |
| 120 | + 0, |
| 121 | + ); |
| 122 | + const totalNet = actionsThisMonth.reduce((sum, item) => sum + (Number(item.netAmount) || 0), 0); |
| 123 | + |
| 124 | + const byRepo = new Map(); |
| 125 | + const bySku = new Map(); |
| 126 | + for (const item of actionsThisMonth) { |
| 127 | + const minutes = Number(item.quantity) || 0; |
| 128 | + const repo = item.repositoryName || '(unknown)'; |
| 129 | + byRepo.set(repo, (byRepo.get(repo) || 0) + minutes); |
| 130 | + const sku = item.sku || '(unknown)'; |
| 131 | + bySku.set(sku, (bySku.get(sku) || 0) + minutes); |
| 132 | + } |
| 133 | + |
| 134 | + const topRepos = [...byRepo.entries()] |
| 135 | + .map(([repo, minutes]) => ({ repository: repo, minutes_used: round(minutes, 1) })) |
| 136 | + .sort((a, b) => b.minutes_used - a.minutes_used) |
| 137 | + .slice(0, 5); |
| 138 | + const skuBreakdown = [...bySku.entries()] |
| 139 | + .map(([sku, minutes]) => ({ sku, minutes_used: round(minutes, 1) })) |
| 140 | + .sort((a, b) => b.minutes_used - a.minutes_used); |
| 141 | + |
| 142 | + return { |
| 143 | + scope, |
| 144 | + name, |
| 145 | + month: targetMonth, |
| 146 | + actions_minutes_used: round(totalMinutes, 1), |
| 147 | + gross_usd: round(totalGross, 2), |
| 148 | + discount_usd: round(totalDiscount, 2), |
| 149 | + net_usd: round(totalNet, 2), |
| 150 | + severity: thresholdSeverity(totalNet, warnUsd, criticalUsd), |
| 151 | + warn_threshold_usd: warnUsd, |
| 152 | + critical_threshold_usd: criticalUsd, |
| 153 | + top_repos: topRepos, |
| 154 | + sku_breakdown: skuBreakdown, |
| 155 | + }; |
| 156 | +} |
| 157 | + |
| 158 | +function round(value, decimals) { |
| 159 | + const factor = 10 ** decimals; |
| 160 | + return Math.round(value * factor) / factor; |
| 161 | +} |
| 162 | + |
| 163 | +function formatBudgetReportText(report) { |
| 164 | + const lines = []; |
| 165 | + lines.push( |
| 166 | + `${TOOL_NAME} budget — GitHub Actions usage for ${report.scope}:${report.name} (${report.month})`, |
| 167 | + ); |
| 168 | + lines.push(` actions minutes used: ${report.actions_minutes_used}`); |
| 169 | + lines.push( |
| 170 | + ` gross: $${report.gross_usd} discount: $${report.discount_usd} net (paid): $${report.net_usd}`, |
| 171 | + ); |
| 172 | + if (report.sku_breakdown.length > 0) { |
| 173 | + lines.push(` by runner sku:`); |
| 174 | + for (const entry of report.sku_breakdown) { |
| 175 | + lines.push(` ${entry.sku}: ${entry.minutes_used} min`); |
| 176 | + } |
| 177 | + } |
| 178 | + if (report.top_repos.length > 0) { |
| 179 | + lines.push(` top repos:`); |
| 180 | + for (const entry of report.top_repos) { |
| 181 | + lines.push(` ${entry.repository}: ${entry.minutes_used} min`); |
| 182 | + } |
| 183 | + } |
| 184 | + const verdict = |
| 185 | + report.severity === 'critical' |
| 186 | + ? `CRITICAL — paid spend $${report.net_usd} this month is at/above $${report.critical_threshold_usd}. Raise the spending limit before the next push to avoid blocked merges.` |
| 187 | + : report.severity === 'warn' |
| 188 | + ? `WARN — paid spend $${report.net_usd} this month exceeds the warn threshold ($${report.warn_threshold_usd}). Review CI triggers or accept the spend.` |
| 189 | + : `OK — no paid spend yet this month (all usage covered by free tier).`; |
| 190 | + lines.push(` status: ${verdict}`); |
| 191 | + return lines.join('\n'); |
| 192 | +} |
| 193 | + |
| 194 | +function parseBudgetArgs(rawArgs) { |
| 195 | + const options = { |
| 196 | + org: null, |
| 197 | + user: null, |
| 198 | + json: false, |
| 199 | + help: false, |
| 200 | + month: null, |
| 201 | + warnUsd: DEFAULT_WARN_NET_USD, |
| 202 | + criticalUsd: DEFAULT_CRITICAL_NET_USD, |
| 203 | + }; |
| 204 | + const args = Array.isArray(rawArgs) ? [...rawArgs] : []; |
| 205 | + while (args.length > 0) { |
| 206 | + const arg = args.shift(); |
| 207 | + if (arg === '--help' || arg === '-h' || arg === 'help') { |
| 208 | + options.help = true; |
| 209 | + continue; |
| 210 | + } |
| 211 | + if (arg === '--json') { |
| 212 | + options.json = true; |
| 213 | + continue; |
| 214 | + } |
| 215 | + if (arg === '--org') { |
| 216 | + options.org = args.shift(); |
| 217 | + continue; |
| 218 | + } |
| 219 | + if (arg === '--user') { |
| 220 | + options.user = args.shift(); |
| 221 | + continue; |
| 222 | + } |
| 223 | + if (arg === '--month') { |
| 224 | + options.month = args.shift(); |
| 225 | + continue; |
| 226 | + } |
| 227 | + if (arg === '--warn-usd') { |
| 228 | + options.warnUsd = Number(args.shift()); |
| 229 | + continue; |
| 230 | + } |
| 231 | + if (arg === '--critical-usd') { |
| 232 | + options.criticalUsd = Number(args.shift()); |
| 233 | + continue; |
| 234 | + } |
| 235 | + if (arg.startsWith('--org=')) { |
| 236 | + options.org = arg.slice('--org='.length); |
| 237 | + continue; |
| 238 | + } |
| 239 | + if (arg.startsWith('--user=')) { |
| 240 | + options.user = arg.slice('--user='.length); |
| 241 | + continue; |
| 242 | + } |
| 243 | + if (arg.startsWith('--month=')) { |
| 244 | + options.month = arg.slice('--month='.length); |
| 245 | + continue; |
| 246 | + } |
| 247 | + if (arg.startsWith('--warn-usd=')) { |
| 248 | + options.warnUsd = Number(arg.slice('--warn-usd='.length)); |
| 249 | + continue; |
| 250 | + } |
| 251 | + if (arg.startsWith('--critical-usd=')) { |
| 252 | + options.criticalUsd = Number(arg.slice('--critical-usd='.length)); |
| 253 | + continue; |
| 254 | + } |
| 255 | + const err = new Error(`Unknown budget argument: ${arg}`); |
| 256 | + err.code = 'BUDGET_BAD_ARG'; |
| 257 | + throw err; |
| 258 | + } |
| 259 | + if (!Number.isFinite(options.warnUsd) || options.warnUsd < 0) { |
| 260 | + throw new Error(`--warn-usd must be a non-negative number; got ${options.warnUsd}`); |
| 261 | + } |
| 262 | + if (!Number.isFinite(options.criticalUsd) || options.criticalUsd < 0) { |
| 263 | + throw new Error(`--critical-usd must be a non-negative number; got ${options.criticalUsd}`); |
| 264 | + } |
| 265 | + return options; |
| 266 | +} |
| 267 | + |
| 268 | +function renderBudgetHelp() { |
| 269 | + return [ |
| 270 | + `${TOOL_NAME} budget — GitHub Actions spend for the current month.`, |
| 271 | + '', |
| 272 | + 'Usage:', |
| 273 | + ` ${TOOL_NAME} budget [--org <name>] [--user <name>] [--month YYYY-MM] [--warn-usd <n>] [--critical-usd <n>] [--json]`, |
| 274 | + '', |
| 275 | + 'Options:', |
| 276 | + ` --org <name> Query an org's billing (requires admin:org on the gh token).`, |
| 277 | + ` --user <name> Query a user's billing (requires user scope on the gh token).`, |
| 278 | + ` --month YYYY-MM Report a specific month (default: current UTC month).`, |
| 279 | + ` --warn-usd <n> Net-paid threshold to flag WARN (default ${DEFAULT_WARN_NET_USD}).`, |
| 280 | + ` --critical-usd <n> Net-paid threshold to flag CRITICAL (default ${DEFAULT_CRITICAL_NET_USD}).`, |
| 281 | + ` --json Emit structured JSON instead of the text summary.`, |
| 282 | + '', |
| 283 | + 'Without --org or --user, the command auto-detects the authenticated login from', |
| 284 | + '`gh api user` and probes the user usage endpoint first, then the org endpoint.', |
| 285 | + '', |
| 286 | + 'Exit codes: 0 ok, 1 error fetching, 2 CRITICAL severity (so CI scripts can fail closed).', |
| 287 | + ].join('\n'); |
| 288 | +} |
| 289 | + |
| 290 | +function runBudgetCommand(rawArgs) { |
| 291 | + let options; |
| 292 | + try { |
| 293 | + options = parseBudgetArgs(rawArgs); |
| 294 | + } catch (err) { |
| 295 | + console.error(`[${TOOL_NAME}] ${err.message}`); |
| 296 | + console.error(renderBudgetHelp()); |
| 297 | + process.exitCode = 1; |
| 298 | + return; |
| 299 | + } |
| 300 | + |
| 301 | + if (options.help) { |
| 302 | + console.log(renderBudgetHelp()); |
| 303 | + return; |
| 304 | + } |
| 305 | + |
| 306 | + let response; |
| 307 | + try { |
| 308 | + response = fetchUsage({ org: options.org, user: options.user }); |
| 309 | + } catch (err) { |
| 310 | + console.error(`[${TOOL_NAME}] ${err.message}`); |
| 311 | + process.exitCode = 1; |
| 312 | + return; |
| 313 | + } |
| 314 | + |
| 315 | + const report = shapeBudgetReport({ |
| 316 | + scope: response.scope, |
| 317 | + name: response.name, |
| 318 | + usage: response.usage, |
| 319 | + monthKey: options.month, |
| 320 | + warnUsd: options.warnUsd, |
| 321 | + criticalUsd: options.criticalUsd, |
| 322 | + }); |
| 323 | + |
| 324 | + if (options.json) { |
| 325 | + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); |
| 326 | + process.exitCode = report.severity === 'critical' ? 2 : 0; |
| 327 | + return; |
| 328 | + } |
| 329 | + |
| 330 | + console.log(formatBudgetReportText(report)); |
| 331 | + process.exitCode = report.severity === 'critical' ? 2 : 0; |
| 332 | +} |
| 333 | + |
| 334 | +module.exports = { |
| 335 | + runBudgetCommand, |
| 336 | + parseBudgetArgs, |
| 337 | + shapeBudgetReport, |
| 338 | + formatBudgetReportText, |
| 339 | + renderBudgetHelp, |
| 340 | + currentMonthKey, |
| 341 | + DEFAULT_WARN_NET_USD, |
| 342 | + DEFAULT_CRITICAL_NET_USD, |
| 343 | +}; |
0 commit comments