diff --git a/openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/.openspec.yaml b/openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/.openspec.yaml new file mode 100644 index 0000000..93831bd --- /dev/null +++ b/openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/notes.md b/openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/notes.md new file mode 100644 index 0000000..5bcfd0e --- /dev/null +++ b/openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/notes.md @@ -0,0 +1,16 @@ +# agent-claude-gx-budget-subcommand-2026-05-14-01-32 (minimal / T1) + +Branch: `agent//` + +Describe the change in a sentence or two. Commit message is the spec of record. + +## Handoff + +- Handoff: change=`agent-claude-gx-budget-subcommand-2026-05-14-01-32`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-gx-budget-subcommand-2026-05-14-01-32` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-gx-budget-subcommand-2026-05-14-01-32/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/budget/index.js b/src/budget/index.js new file mode 100644 index 0000000..376d511 --- /dev/null +++ b/src/budget/index.js @@ -0,0 +1,343 @@ +'use strict'; + +const cp = require('node:child_process'); + +const TOOL_NAME = 'gx'; + +const DEFAULT_WARN_NET_USD = 1; // any paid spend at all +const DEFAULT_CRITICAL_NET_USD = 10; // paid spend that has caused merge blocks before + +function runGh(args) { + const result = cp.spawnSync('gh', args, { encoding: 'utf8' }); + if (result.error) { + const err = new Error(`gh binary not found: ${result.error.message}`); + err.code = 'GH_BIN_MISSING'; + throw err; + } + return result; +} + +function ghApi(endpoint) { + const result = runGh(['api', endpoint]); + if (result.status !== 0) { + const message = (result.stderr || result.stdout || '').trim(); + if (/404/.test(message)) { + const err = new Error(`GitHub API 404: ${endpoint}`); + err.code = 'GH_API_NOT_FOUND'; + throw err; + } + if (/403/.test(message)) { + const err = new Error( + `GitHub API 403: ${endpoint}. The current token lacks the billing scope (org owners need admin:org; user accounts need user scope).`, + ); + err.code = 'GH_API_FORBIDDEN'; + throw err; + } + if (/410/.test(message)) { + const err = new Error( + `GitHub API 410: ${endpoint}. This endpoint was retired in early 2026; the new enhanced billing endpoint is /{scope}/{name}/settings/billing/usage.`, + ); + err.code = 'GH_API_GONE'; + throw err; + } + throw new Error(`gh api ${endpoint} failed: ${message}`); + } + try { + return JSON.parse(result.stdout); + } catch (parseErr) { + throw new Error(`gh api ${endpoint} returned non-JSON output: ${parseErr.message}`); + } +} + +function detectCurrentLogin() { + const result = runGh(['api', 'user', '--jq', '.login']); + if (result.status !== 0) return null; + return result.stdout.trim() || null; +} + +function fetchUsage({ org, user } = {}) { + if (org) { + const usage = ghApi(`/orgs/${org}/settings/billing/usage`); + return { scope: 'org', name: org, usage }; + } + if (user) { + const usage = ghApi(`/users/${user}/settings/billing/usage`); + return { scope: 'user', name: user, usage }; + } + const login = detectCurrentLogin(); + if (!login) { + throw new Error( + `Could not detect the authenticated login. Pass --org or --user explicitly.`, + ); + } + try { + const usage = ghApi(`/users/${login}/settings/billing/usage`); + return { scope: 'user', name: login, usage }; + } catch (err) { + if (err.code === 'GH_API_NOT_FOUND') { + const usage = ghApi(`/orgs/${login}/settings/billing/usage`); + return { scope: 'org', name: login, usage }; + } + throw err; + } +} + +function currentMonthKey(now = new Date()) { + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} + +function itemMonthKey(item) { + // Dates land as 'YYYY-MM-01T00:00:00Z' for the start of a billed month. + return typeof item.date === 'string' ? item.date.slice(0, 7) : ''; +} + +function thresholdSeverity(netUsd, warnUsd, criticalUsd) { + if (netUsd >= criticalUsd) return 'critical'; + if (netUsd >= warnUsd) return 'warn'; + return 'ok'; +} + +function shapeBudgetReport({ scope, name, usage, monthKey, warnUsd, criticalUsd }) { + const items = Array.isArray(usage?.usageItems) ? usage.usageItems : []; + const targetMonth = monthKey ?? currentMonthKey(); + + const actionsThisMonth = items.filter( + (item) => + item.product === 'actions' && + item.unitType === 'Minutes' && + itemMonthKey(item) === targetMonth, + ); + + const totalMinutes = actionsThisMonth.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0); + const totalGross = actionsThisMonth.reduce( + (sum, item) => sum + (Number(item.grossAmount) || 0), + 0, + ); + const totalDiscount = actionsThisMonth.reduce( + (sum, item) => sum + (Number(item.discountAmount) || 0), + 0, + ); + const totalNet = actionsThisMonth.reduce((sum, item) => sum + (Number(item.netAmount) || 0), 0); + + const byRepo = new Map(); + const bySku = new Map(); + for (const item of actionsThisMonth) { + const minutes = Number(item.quantity) || 0; + const repo = item.repositoryName || '(unknown)'; + byRepo.set(repo, (byRepo.get(repo) || 0) + minutes); + const sku = item.sku || '(unknown)'; + bySku.set(sku, (bySku.get(sku) || 0) + minutes); + } + + const topRepos = [...byRepo.entries()] + .map(([repo, minutes]) => ({ repository: repo, minutes_used: round(minutes, 1) })) + .sort((a, b) => b.minutes_used - a.minutes_used) + .slice(0, 5); + const skuBreakdown = [...bySku.entries()] + .map(([sku, minutes]) => ({ sku, minutes_used: round(minutes, 1) })) + .sort((a, b) => b.minutes_used - a.minutes_used); + + return { + scope, + name, + month: targetMonth, + actions_minutes_used: round(totalMinutes, 1), + gross_usd: round(totalGross, 2), + discount_usd: round(totalDiscount, 2), + net_usd: round(totalNet, 2), + severity: thresholdSeverity(totalNet, warnUsd, criticalUsd), + warn_threshold_usd: warnUsd, + critical_threshold_usd: criticalUsd, + top_repos: topRepos, + sku_breakdown: skuBreakdown, + }; +} + +function round(value, decimals) { + const factor = 10 ** decimals; + return Math.round(value * factor) / factor; +} + +function formatBudgetReportText(report) { + const lines = []; + lines.push( + `${TOOL_NAME} budget — GitHub Actions usage for ${report.scope}:${report.name} (${report.month})`, + ); + lines.push(` actions minutes used: ${report.actions_minutes_used}`); + lines.push( + ` gross: $${report.gross_usd} discount: $${report.discount_usd} net (paid): $${report.net_usd}`, + ); + if (report.sku_breakdown.length > 0) { + lines.push(` by runner sku:`); + for (const entry of report.sku_breakdown) { + lines.push(` ${entry.sku}: ${entry.minutes_used} min`); + } + } + if (report.top_repos.length > 0) { + lines.push(` top repos:`); + for (const entry of report.top_repos) { + lines.push(` ${entry.repository}: ${entry.minutes_used} min`); + } + } + const verdict = + report.severity === 'critical' + ? `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.` + : report.severity === 'warn' + ? `WARN — paid spend $${report.net_usd} this month exceeds the warn threshold ($${report.warn_threshold_usd}). Review CI triggers or accept the spend.` + : `OK — no paid spend yet this month (all usage covered by free tier).`; + lines.push(` status: ${verdict}`); + return lines.join('\n'); +} + +function parseBudgetArgs(rawArgs) { + const options = { + org: null, + user: null, + json: false, + help: false, + month: null, + warnUsd: DEFAULT_WARN_NET_USD, + criticalUsd: DEFAULT_CRITICAL_NET_USD, + }; + const args = Array.isArray(rawArgs) ? [...rawArgs] : []; + while (args.length > 0) { + const arg = args.shift(); + if (arg === '--help' || arg === '-h' || arg === 'help') { + options.help = true; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg === '--org') { + options.org = args.shift(); + continue; + } + if (arg === '--user') { + options.user = args.shift(); + continue; + } + if (arg === '--month') { + options.month = args.shift(); + continue; + } + if (arg === '--warn-usd') { + options.warnUsd = Number(args.shift()); + continue; + } + if (arg === '--critical-usd') { + options.criticalUsd = Number(args.shift()); + continue; + } + if (arg.startsWith('--org=')) { + options.org = arg.slice('--org='.length); + continue; + } + if (arg.startsWith('--user=')) { + options.user = arg.slice('--user='.length); + continue; + } + if (arg.startsWith('--month=')) { + options.month = arg.slice('--month='.length); + continue; + } + if (arg.startsWith('--warn-usd=')) { + options.warnUsd = Number(arg.slice('--warn-usd='.length)); + continue; + } + if (arg.startsWith('--critical-usd=')) { + options.criticalUsd = Number(arg.slice('--critical-usd='.length)); + continue; + } + const err = new Error(`Unknown budget argument: ${arg}`); + err.code = 'BUDGET_BAD_ARG'; + throw err; + } + if (!Number.isFinite(options.warnUsd) || options.warnUsd < 0) { + throw new Error(`--warn-usd must be a non-negative number; got ${options.warnUsd}`); + } + if (!Number.isFinite(options.criticalUsd) || options.criticalUsd < 0) { + throw new Error(`--critical-usd must be a non-negative number; got ${options.criticalUsd}`); + } + return options; +} + +function renderBudgetHelp() { + return [ + `${TOOL_NAME} budget — GitHub Actions spend for the current month.`, + '', + 'Usage:', + ` ${TOOL_NAME} budget [--org ] [--user ] [--month YYYY-MM] [--warn-usd ] [--critical-usd ] [--json]`, + '', + 'Options:', + ` --org Query an org's billing (requires admin:org on the gh token).`, + ` --user Query a user's billing (requires user scope on the gh token).`, + ` --month YYYY-MM Report a specific month (default: current UTC month).`, + ` --warn-usd Net-paid threshold to flag WARN (default ${DEFAULT_WARN_NET_USD}).`, + ` --critical-usd Net-paid threshold to flag CRITICAL (default ${DEFAULT_CRITICAL_NET_USD}).`, + ` --json Emit structured JSON instead of the text summary.`, + '', + 'Without --org or --user, the command auto-detects the authenticated login from', + '`gh api user` and probes the user usage endpoint first, then the org endpoint.', + '', + 'Exit codes: 0 ok, 1 error fetching, 2 CRITICAL severity (so CI scripts can fail closed).', + ].join('\n'); +} + +function runBudgetCommand(rawArgs) { + let options; + try { + options = parseBudgetArgs(rawArgs); + } catch (err) { + console.error(`[${TOOL_NAME}] ${err.message}`); + console.error(renderBudgetHelp()); + process.exitCode = 1; + return; + } + + if (options.help) { + console.log(renderBudgetHelp()); + return; + } + + let response; + try { + response = fetchUsage({ org: options.org, user: options.user }); + } catch (err) { + console.error(`[${TOOL_NAME}] ${err.message}`); + process.exitCode = 1; + return; + } + + const report = shapeBudgetReport({ + scope: response.scope, + name: response.name, + usage: response.usage, + monthKey: options.month, + warnUsd: options.warnUsd, + criticalUsd: options.criticalUsd, + }); + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.severity === 'critical' ? 2 : 0; + return; + } + + console.log(formatBudgetReportText(report)); + process.exitCode = report.severity === 'critical' ? 2 : 0; +} + +module.exports = { + runBudgetCommand, + parseBudgetArgs, + shapeBudgetReport, + formatBudgetReportText, + renderBudgetHelp, + currentMonthKey, + DEFAULT_WARN_NET_USD, + DEFAULT_CRITICAL_NET_USD, +}; diff --git a/src/cli/main.js b/src/cli/main.js index 1046af2..8e5f6d7 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -11,6 +11,7 @@ const agentStatus = require('../agents/status'); const agentCleanupSessions = require('../agents/cleanup-sessions'); const { finishAgentSession } = require('../agents/finish'); const sessionSeverityReport = require('../report/session-severity'); +const budgetModule = require('../budget'); const cockpitModule = require('../cockpit'); const agentsStart = require('../agents/start'); const prReviewModule = require('../pr-review'); @@ -3971,6 +3972,7 @@ async function main() { if (command === 'submodule') return submodule(rest); if (command === 'cleanup') return cleanup(rest); if (command === 'release') return release(rest); + if (command === 'budget') return budgetModule.runBudgetCommand(rest); const suggestion = maybeSuggestCommand(command); if (suggestion) { diff --git a/src/context.js b/src/context.js index 6a83402..004bab2 100644 --- a/src/context.js +++ b/src/context.js @@ -414,6 +414,7 @@ const SUGGESTIBLE_COMMANDS = [ 'copy-commands', 'print-agents-snippet', 'release', + 'budget', ]; // CLI_COMMAND_GROUPS is the grouped source of truth the `gx --help` / // `gx` no-args renderer uses. Each group is ordered roughly by how often a diff --git a/test/budget.test.js b/test/budget.test.js new file mode 100644 index 0000000..3510e86 --- /dev/null +++ b/test/budget.test.js @@ -0,0 +1,179 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseBudgetArgs, + shapeBudgetReport, + formatBudgetReportText, + renderBudgetHelp, + currentMonthKey, + DEFAULT_WARN_NET_USD, + DEFAULT_CRITICAL_NET_USD, +} = require('../src/budget'); + +const sampleUsage = { + usageItems: [ + { + date: '2026-05-01T00:00:00Z', + product: 'actions', + sku: 'Actions Linux', + quantity: 155184, + unitType: 'Minutes', + pricePerUnit: 0.006, + grossAmount: 931.104, + discountAmount: 931.104, + netAmount: 0, + repositoryName: 'gitguardex', + }, + { + date: '2026-05-01T00:00:00Z', + product: 'actions', + sku: 'Actions macOS 3-core', + quantity: 150, + unitType: 'Minutes', + pricePerUnit: 0.062, + grossAmount: 9.3, + discountAmount: 0, + netAmount: 9.3, + repositoryName: 'openthread-dashboard', + }, + { + date: '2026-05-01T00:00:00Z', + product: 'actions', + sku: 'Actions storage', + quantity: 134.78, + unitType: 'GigabyteHours', + pricePerUnit: 0.00033602, + grossAmount: 0.045, + discountAmount: 0.045, + netAmount: 0, + repositoryName: 'oh-my-codex', + }, + { + date: '2026-04-01T00:00:00Z', + product: 'actions', + sku: 'Actions Linux', + quantity: 179300, + unitType: 'Minutes', + pricePerUnit: 0.006, + grossAmount: 1075.8, + discountAmount: 1075.8, + netAmount: 0, + repositoryName: 'codex-plugins', + }, + ], +}; + +test('parseBudgetArgs accepts the documented flags', () => { + assert.deepEqual(parseBudgetArgs([]), { + org: null, + user: null, + json: false, + help: false, + month: null, + warnUsd: DEFAULT_WARN_NET_USD, + criticalUsd: DEFAULT_CRITICAL_NET_USD, + }); + const parsed = parseBudgetArgs([ + '--org=recodeee', + '--month', + '2026-05', + '--warn-usd=5', + '--critical-usd', + '50', + '--json', + ]); + assert.equal(parsed.org, 'recodeee'); + assert.equal(parsed.month, '2026-05'); + assert.equal(parsed.warnUsd, 5); + assert.equal(parsed.criticalUsd, 50); + assert.equal(parsed.json, true); +}); + +test('parseBudgetArgs rejects unknown flags + bad numbers', () => { + assert.throws(() => parseBudgetArgs(['--bogus']), /Unknown budget argument: --bogus/); + assert.throws(() => parseBudgetArgs(['--warn-usd=abc']), /--warn-usd must be a non-negative/); +}); + +test('currentMonthKey returns YYYY-MM in UTC', () => { + const fixed = new Date('2026-05-14T01:00:00Z'); + assert.equal(currentMonthKey(fixed), '2026-05'); +}); + +test('shapeBudgetReport filters by current month + product + minutes only', () => { + const report = shapeBudgetReport({ + scope: 'org', + name: 'recodeee', + usage: sampleUsage, + monthKey: '2026-05', + warnUsd: DEFAULT_WARN_NET_USD, + criticalUsd: DEFAULT_CRITICAL_NET_USD, + }); + // Should include only the 2 May Actions/Minutes items (Linux + macOS). + // April Linux row + May storage row are excluded. + assert.equal(report.actions_minutes_used, 155334); + assert.equal(report.net_usd, 9.3); + assert.equal(report.severity, 'warn'); // net 9.3 < critical 10 but > warn 1 + const linuxRow = report.sku_breakdown.find((row) => row.sku === 'Actions Linux'); + const macRow = report.sku_breakdown.find((row) => row.sku === 'Actions macOS 3-core'); + assert.equal(linuxRow?.minutes_used, 155184); + assert.equal(macRow?.minutes_used, 150); +}); + +test('shapeBudgetReport surfaces critical when net spend reaches the threshold', () => { + const big = shapeBudgetReport({ + scope: 'org', + name: 'recodeee', + usage: { + usageItems: [ + { + date: '2026-05-01T00:00:00Z', + product: 'actions', + sku: 'Actions Linux', + quantity: 5000, + unitType: 'Minutes', + grossAmount: 30, + discountAmount: 0, + netAmount: 30, + repositoryName: 'gitguardex', + }, + ], + }, + monthKey: '2026-05', + warnUsd: DEFAULT_WARN_NET_USD, + criticalUsd: DEFAULT_CRITICAL_NET_USD, + }); + assert.equal(big.severity, 'critical'); +}); + +test('formatBudgetReportText surfaces CRITICAL verdict', () => { + const text = formatBudgetReportText({ + scope: 'org', + name: 'recodeee', + month: '2026-05', + actions_minutes_used: 155334, + gross_usd: 940.4, + discount_usd: 931.1, + net_usd: 100, + severity: 'critical', + warn_threshold_usd: 1, + critical_threshold_usd: 10, + top_repos: [{ repository: 'gitguardex', minutes_used: 155184 }], + sku_breakdown: [{ sku: 'Actions Linux', minutes_used: 155184 }], + }); + assert.match(text, /CRITICAL/); + assert.match(text, /Actions Linux: 155184 min/); + assert.match(text, /gitguardex: 155184 min/); +}); + +test('renderBudgetHelp covers documented flags', () => { + const help = renderBudgetHelp(); + assert.match(help, /--org/); + assert.match(help, /--user/); + assert.match(help, /--month/); + assert.match(help, /--warn-usd/); + assert.match(help, /--critical-usd/); + assert.match(help, /--json/); +});