Skip to content

Commit 8b50cc2

Browse files
NagyViktNagyViktclaude
authored
feat(cli): gx budget — Actions usage + paid-spend thresholds (#574)
Wraps the new GitHub /settings/billing/usage endpoint (replaces the 410'd /settings/billing/actions endpoint) and aggregates Actions minute spend for the current month per org or user. gx budget [--org <name>] [--user <name>] [--month YYYY-MM] [--warn-usd <n>] [--critical-usd <n>] [--json] Auto-detects the authenticated login from `gh api user` and probes the user endpoint first, then the org endpoint. Output includes minutes used, gross/discount/net USD, top repos, runner SKU breakdown, and a verdict against the warn/critical net-paid thresholds (default $1 / $10). Critical severity exits 2 so CI scripts can fail closed. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8939f32 commit 8b50cc2

6 files changed

Lines changed: 543 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-13
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# agent-claude-gx-budget-subcommand-2026-05-14-01-32 (minimal / T1)
2+
3+
Branch: `agent/<your-name>/<branch-slug>`
4+
5+
Describe the change in a sentence or two. Commit message is the spec of record.
6+
7+
## Handoff
8+
9+
- Handoff: change=`agent-claude-gx-budget-subcommand-2026-05-14-01-32`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
10+
- Copy prompt: Continue `agent-claude-gx-budget-subcommand-2026-05-14-01-32` on branch `agent/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.
11+
12+
## Cleanup
13+
14+
- [ ] Run: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`
15+
- [ ] Record PR URL + `MERGED` state in the completion handoff.
16+
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).

src/budget/index.js

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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+
};

src/cli/main.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const agentStatus = require('../agents/status');
1111
const agentCleanupSessions = require('../agents/cleanup-sessions');
1212
const { finishAgentSession } = require('../agents/finish');
1313
const sessionSeverityReport = require('../report/session-severity');
14+
const budgetModule = require('../budget');
1415
const cockpitModule = require('../cockpit');
1516
const agentsStart = require('../agents/start');
1617
const prReviewModule = require('../pr-review');
@@ -3971,6 +3972,7 @@ async function main() {
39713972
if (command === 'submodule') return submodule(rest);
39723973
if (command === 'cleanup') return cleanup(rest);
39733974
if (command === 'release') return release(rest);
3975+
if (command === 'budget') return budgetModule.runBudgetCommand(rest);
39743976

39753977
const suggestion = maybeSuggestCommand(command);
39763978
if (suggestion) {

src/context.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ const SUGGESTIBLE_COMMANDS = [
414414
'copy-commands',
415415
'print-agents-snippet',
416416
'release',
417+
'budget',
417418
];
418419
// CLI_COMMAND_GROUPS is the grouped source of truth the `gx --help` /
419420
// `gx` no-args renderer uses. Each group is ordered roughly by how often a

0 commit comments

Comments
 (0)