@cad0p/pi-usage 0.0.0-stage → 0.11.2

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +509 -2
  3. package/bin/piquota.js +199 -0
  4. package/extensions/quota-panel.ts +963 -0
  5. package/package.json +59 -4
  6. package/src/auth/claude-code-auth.js +490 -0
  7. package/src/auth/jwt.js +84 -0
  8. package/src/auth/pi-auth.js +424 -0
  9. package/src/browser/cookies.js +557 -0
  10. package/src/browser/history.js +173 -0
  11. package/src/cache.js +224 -0
  12. package/src/cli/args.js +154 -0
  13. package/src/cli/commands/auth.js +216 -0
  14. package/src/cli/commands/gentle-pi.js +44 -0
  15. package/src/cli/commands/omarchy.js +75 -0
  16. package/src/cli/explain.js +99 -0
  17. package/src/cli/output.js +34 -0
  18. package/src/engine.js +404 -0
  19. package/src/exec.js +31 -0
  20. package/src/gentle-pi/rail-patch.js +335 -0
  21. package/src/http.js +182 -0
  22. package/src/model.js +332 -0
  23. package/src/omarchy/assets/antigravity.svg +3 -0
  24. package/src/omarchy/assets/claude.svg +1 -0
  25. package/src/omarchy/assets/codex.svg +1 -0
  26. package/src/omarchy/assets/github-actions.svg +3 -0
  27. package/src/omarchy/assets/opencode-go.svg +3 -0
  28. package/src/omarchy/publish.js +152 -0
  29. package/src/omarchy/record.js +462 -0
  30. package/src/opencode/dashboard.js +337 -0
  31. package/src/opencode/session.js +485 -0
  32. package/src/providers/antigravity-oauth.js +173 -0
  33. package/src/providers/antigravity.js +225 -0
  34. package/src/providers/backoff.js +146 -0
  35. package/src/providers/claude.js +194 -0
  36. package/src/providers/codex.js +151 -0
  37. package/src/providers/error-kind.js +128 -0
  38. package/src/providers/github-actions.js +379 -0
  39. package/src/providers/opencode-go.js +274 -0
  40. package/src/refresh.js +292 -0
  41. package/src/render/panel.js +411 -0
  42. package/src/render/theme.js +102 -0
  43. package/src/sticky.js +319 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * OpenAI Codex quota from a Pi OAuth access token.
3
+ *
4
+ * Endpoint: GET https://chatgpt.com/backend-api/wham/usage
5
+ * Required headers: Bearer token, `chatgpt-account-id` (taken from the JWT claim
6
+ * `https://api.openai.com/auth.chatgpt_account_id`) and the Codex originator.
7
+ *
8
+ * Windows: `rate_limit.primary_window` (5h) and `rate_limit.secondary_window`
9
+ * (weekly), each `{ used_percent, limit_window_seconds, reset_after_seconds, reset_at }`.
10
+ *
11
+ * No refresh is attempted: OpenAI rotates the refresh token, so refreshing here
12
+ * would invalidate the copy Pi has stored.
13
+ */
14
+
15
+ import { buildWindow, clampPercent, degradedResult, displayIdentity, finiteNumber, parseReset, parseResetSeconds, stringValue, windowFromSeconds } from "../model.js";
16
+ import { requestJson } from "../http.js";
17
+ import { extractChatGptAccountId } from "../auth/jwt.js";
18
+
19
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
20
+
21
+ /**
22
+ * @param {unknown} value
23
+ * @returns {Record<string, unknown> | null}
24
+ */
25
+ function record(value) {
26
+ return value && typeof value === "object" && !Array.isArray(value)
27
+ ? /** @type {Record<string, unknown>} */ (value)
28
+ : null;
29
+ }
30
+
31
+ /**
32
+ * @param {Record<string, unknown>} window
33
+ * @param {string} fallbackId
34
+ * @param {number} now
35
+ * @returns {import("../model.js").QuotaWindow | null}
36
+ */
37
+ function windowFromRateLimit(window, fallbackId, now) {
38
+ const used = clampPercent(window.used_percent ?? window.usedPercent);
39
+ if (used === null) return null;
40
+
41
+ const windowSeconds = finiteNumber(window.limit_window_seconds);
42
+ const derived = windowFromSeconds(windowSeconds);
43
+ const id = windowSeconds === null ? fallbackId : derived.id;
44
+ const label = windowSeconds === null
45
+ ? (fallbackId === "5h" ? "5h window" : "Weekly window")
46
+ : derived.label;
47
+
48
+ const explicitReset = parseReset(window.reset_at, now);
49
+ const resets = explicitReset.resetsAt !== null
50
+ ? explicitReset
51
+ : parseResetSeconds(window.reset_after_seconds, now);
52
+
53
+ return buildWindow({
54
+ id,
55
+ label,
56
+ usedPercent: used,
57
+ resetsAt: resets.resetsAt,
58
+ windowSeconds,
59
+ now,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * @param {import("../auth/pi-auth.js").PiCredential} credential
65
+ * @param {{ now?: number, fetchFn?: typeof fetch, timeoutMs?: number, expiresInMin?: number | null }} [options]
66
+ * @returns {Promise<import("../model.js").QuotaResult>}
67
+ */
68
+ export async function fetchQuota(credential, options = {}) {
69
+ const now = options.now ?? Date.now();
70
+ const accountId = credential.accountId ?? (credential.access ? extractChatGptAccountId(credential.access) : null);
71
+ const base = {
72
+ family: "codex",
73
+ label: "Codex (Pi)",
74
+ account: displayIdentity({ ...credential, accountId }),
75
+ plan: credential.planType,
76
+ source: credential.source,
77
+ sourceKind: credential.sourceKind ?? "pi",
78
+ expiresInMin: options.expiresInMin ?? null,
79
+ };
80
+
81
+ if (!credential.access) {
82
+ return degradedResult({ ...base, error: "Pi store has no openai-codex access token; run /login openai-codex in Pi" });
83
+ }
84
+
85
+ /** @type {Record<string, string>} */
86
+ const headers = {
87
+ Authorization: `Bearer ${credential.access}`,
88
+ accept: "application/json",
89
+ originator: "codex_cli_rs",
90
+ };
91
+ if (accountId) headers["chatgpt-account-id"] = accountId;
92
+
93
+ const response = await requestJson(USAGE_URL, { headers, fetchFn: options.fetchFn, timeoutMs: options.timeoutMs });
94
+ if (!response.ok) {
95
+ const error = response.authError
96
+ ? "Codex token expired or rejected; use any Codex model in Pi to refresh it"
97
+ : `Codex usage request failed: ${response.error}`;
98
+ return degradedResult({ ...base, error });
99
+ }
100
+
101
+ const body = record(response.body) ?? {};
102
+ const rateLimit = record(body.rate_limit) ?? {};
103
+ const plan = stringValue(body.plan_type) ?? credential.planType ?? null;
104
+
105
+ /** @type {import("../model.js").QuotaWindow[]} */
106
+ const windows = [];
107
+ const primary = record(rateLimit.primary_window);
108
+ const secondary = record(rateLimit.secondary_window);
109
+ if (primary) {
110
+ const window = windowFromRateLimit(primary, "5h", now);
111
+ if (window) windows.push(window);
112
+ }
113
+ if (secondary) {
114
+ const window = windowFromRateLimit(secondary, "weekly", now);
115
+ if (window) windows.push(window);
116
+ }
117
+
118
+ const credits = record(body.credits);
119
+ if (credits && credits.has_credits === true && credits.unlimited !== true) {
120
+ const balance = finiteNumber(credits.balance);
121
+ if (balance !== null && balance > 0) {
122
+ windows.push(
123
+ buildWindow({
124
+ id: "credits",
125
+ label: "Credits",
126
+ usedPercent: null,
127
+ remainingPercent: null,
128
+ note: `${balance} credits available`,
129
+ now,
130
+ }),
131
+ );
132
+ }
133
+ }
134
+
135
+ if (windows.length === 0) {
136
+ return degradedResult({
137
+ ...base,
138
+ plan,
139
+ error: "Codex usage response had no primary/secondary window (API shape may have changed)",
140
+ });
141
+ }
142
+
143
+ return {
144
+ ...base,
145
+ plan,
146
+ windows,
147
+ error: null,
148
+ ok: true,
149
+ updatedAt: new Date(now).toISOString(),
150
+ };
151
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Classify a provider error message so renderers can pick a distinct glyph,
3
+ * colour and tooltip per cause.
4
+ *
5
+ * Every renderer collapses "failed" into one symbol today, which is the
6
+ * visible symptom the user reported: a provider that is just backing off
7
+ * reads the same as one whose sign-in has expired. Distinguishing the two
8
+ * makes the line actionable instead of alarming.
9
+ *
10
+ * Categories:
11
+ *
12
+ * `transient` — upstream briefly unavailable (timeout, ECONNRESET, 5xx).
13
+ * mergeLastGood() can substitute the previous good snapshot.
14
+ * `throttle` — the vendor returned 429 and we are in a backoff window.
15
+ * mergeLastGood() can substitute too.
16
+ * `expired` — the stored OAuth/API token is past its expiry. Action
17
+ * required; the line must say so clearly.
18
+ * `auth` — the upstream rejected the credential (401/403) but the
19
+ * token is not yet past its stored expiry. Action required.
20
+ * `missing` — no credential or no cookie was found at all. Tells the
21
+ * user to log in, not that anything is broken.
22
+ * `unknown` — anything else. Better than "!" with no hint.
23
+ *
24
+ * The classification is intentionally regex-based and conservative: a
25
+ * misclassified permanent error masked as transient would hide a real
26
+ * problem. Only patterns that already exist in the codebase (see
27
+ * providers/backoff.js for `isAuthFailure`, sticky.js for the
28
+ * `TRANSIENT` set) qualify.
29
+ */
30
+
31
+ export const ERROR_KINDS = ["transient", "throttle", "expired", "auth", "missing", "unknown"];
32
+
33
+ /** @typedef {"transient" | "throttle" | "expired" | "auth" | "missing" | "unknown"} ErrorKind */
34
+
35
+ /**
36
+ * @param {string | null | undefined} error
37
+ * @returns {ErrorKind}
38
+ */
39
+ export function errorKind(error) {
40
+ if (!error || typeof error !== "string") return "unknown";
41
+ if (
42
+ /no [a-z-]+ credential|has no (anthropic|openai-codex|antigravity|opencode-go) (access )?token|no key stored|no "auth" cookie|no opencode\.ai session/i.test(
43
+ error,
44
+ )
45
+ ) {
46
+ return "missing";
47
+ }
48
+ if (
49
+ /token expired|token EXPIRED|token -?\d+m|expires in -\d+|sign-in expired|re-authenticate|expired or rejected|rejected by .* zen|rejected; run \/login|reconnect OpenCode in Pi/i.test(
50
+ error,
51
+ )
52
+ ) {
53
+ return "expired";
54
+ }
55
+ if (/HTTP 401|HTTP 403|401|403|unauthorized|forbidden|invalid_grant|token rejected/i.test(error)) {
56
+ return "auth";
57
+ }
58
+ if (/HTTP 429|rate limited|backing off|throttle/i.test(error)) {
59
+ return "throttle";
60
+ }
61
+ if (/HTTP 5\d\d|timed out|timeout|ECONNRESET|socket|network|fetch failed|ENOTFOUND|ETIMEDOUT|EPIPE|ECONNREFUSED|aborted|hang up/i.test(error)) {
62
+ return "transient";
63
+ }
64
+ return "unknown";
65
+ }
66
+
67
+ /**
68
+ * Whether `errorKind()` will let mergeLastGood() keep the previous good
69
+ * snapshot. Only `transient` and `throttle` qualify; everything else points
70
+ * at a real problem that must not be masked.
71
+ *
72
+ * @param {string | null | undefined} error
73
+ * @returns {boolean}
74
+ */
75
+ export function isRecoverableError(error) {
76
+ const kind = errorKind(error);
77
+ return kind === "transient" || kind === "throttle";
78
+ }
79
+
80
+ /**
81
+ * The glyph a renderer should paint when the provider has no usable windows.
82
+ * Distinct from `?` (which means "no percentage reported, but the provider
83
+ * answered") so the line is not ambiguous.
84
+ *
85
+ * @param {string | null | undefined} error
86
+ * @returns {string}
87
+ */
88
+ export function errorGlyph(error) {
89
+ switch (errorKind(error)) {
90
+ case "transient":
91
+ return "~";
92
+ case "throttle":
93
+ return "…";
94
+ case "expired":
95
+ case "auth":
96
+ return "!";
97
+ case "missing":
98
+ return "·";
99
+ default:
100
+ return "?";
101
+ }
102
+ }
103
+
104
+ /**
105
+ * A one-line, user-facing description of the failure, safe to surface in a
106
+ * tooltip or under a glyph in the compact line. Already redacted upstream.
107
+ *
108
+ * @param {string | null | undefined} error
109
+ * @returns {string}
110
+ */
111
+ export function errorCaption(error) {
112
+ const kind = errorKind(error);
113
+ if (!error) return kind === "unknown" ? "no data" : "no data";
114
+ switch (kind) {
115
+ case "throttle":
116
+ return "rate-limited upstream";
117
+ case "transient":
118
+ return "upstream temporarily unavailable";
119
+ case "expired":
120
+ return "token expired";
121
+ case "auth":
122
+ return "credential rejected";
123
+ case "missing":
124
+ return "not configured";
125
+ default:
126
+ return "unavailable";
127
+ }
128
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * GitHub Actions monthly quota from the user's already-authenticated `gh` CLI.
3
+ *
4
+ * The GitHub token is never read by piQuota: requests are delegated to `gh api`,
5
+ * which receives GH_TOKEN/GITHUB_TOKEN only through its child-process environment.
6
+ * The provider has no Pi-store credential; the engine passes a non-secret descriptor
7
+ * so normal provider bookkeeping can remain shared without fabricating a token.
8
+ */
9
+
10
+ import { runCommand as defaultRunCommand } from "../exec.js";
11
+ import { buildWindow, degradedResult, windowFromSeconds } from "../model.js";
12
+ import { redact } from "../http.js";
13
+
14
+ const FAMILY = "github-actions";
15
+ const LABEL = "GitHub Actions";
16
+ const SOURCE = "gh CLI";
17
+ const DEFAULT_ORG = "KoralisSoft";
18
+ const ALLOWANCES = { free: 2000, pro: 3000, team: 3000, enterprise_cloud: 50000 };
19
+ const LINUX_MINUTES = 30 * 24 * 60 * 60;
20
+
21
+ /** @param {unknown} value */
22
+ function record(value) {
23
+ return value && typeof value === "object" && !Array.isArray(value)
24
+ ? /** @type {Record<string, unknown>} */ (value)
25
+ : null;
26
+ }
27
+
28
+ /**
29
+ * Apply the shared redactor plus GitHub-specific token patterns (ghp_/gho_ and
30
+ * fine-grained github_pat_ tokens are not part of the shared HTTP redactor yet).
31
+ * @param {unknown} value
32
+ * @returns {string}
33
+ */
34
+ function safeText(value) {
35
+ return redact(value)
36
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]+/g, "<redacted>")
37
+ .replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "<redacted>");
38
+ }
39
+
40
+ /**
41
+ * @param {string} path
42
+ * @param {{ runCommand: typeof defaultRunCommand, timeoutMs: number, env: Record<string, string | undefined> }} options
43
+ * @returns {Promise<{ ok: true, body: unknown } | { ok: false, error: string }>}
44
+ */
45
+ async function requestGhJson(path, options) {
46
+ let result;
47
+ try {
48
+ result = await options.runCommand(
49
+ "gh",
50
+ ["api", "-H", "Accept: application/vnd.github+json", path],
51
+ { timeoutMs: options.timeoutMs, env: options.env },
52
+ );
53
+ } catch (error) {
54
+ return { ok: false, error: `GitHub Actions could not run the gh CLI: ${safeText(error?.message ?? error)}. Install GitHub CLI from https://cli.github.com/ and try again.` };
55
+ }
56
+
57
+ if (result?.status === null) {
58
+ return {
59
+ ok: false,
60
+ error: "GitHub Actions requires the gh CLI, but it is not installed or could not start; install it from https://cli.github.com/ and try again.",
61
+ };
62
+ }
63
+ if (result?.status !== 0) {
64
+ const detail = safeText(result?.stderr || result?.error || `gh exited with status ${result?.status ?? "unknown"}`);
65
+ const lower = detail.toLowerCase();
66
+ if (/\b404\b|not found/.test(lower)) {
67
+ return {
68
+ ok: false,
69
+ error: `GitHub organization was not found or your account cannot access it (${detail}); verify PI_QUOTA_GITHUB_ORG and your organization membership.`,
70
+ };
71
+ }
72
+ if (/\b401\b|not logged|authentication|unauthorized/.test(lower)) {
73
+ return {
74
+ ok: false,
75
+ error: `GitHub gh CLI is not authenticated (${detail}); run \`gh auth login\` and confirm \`gh auth status\` succeeds.`,
76
+ };
77
+ }
78
+ if (/\b403\b|forbidden|resource not accessible/.test(lower)) {
79
+ return {
80
+ ok: false,
81
+ error: `GitHub denied organization billing access (${detail}); run \`gh auth refresh -h github.com -s read:org,repo,workflow\` and verify you can view the organization's billing usage.`,
82
+ };
83
+ }
84
+ return {
85
+ ok: false,
86
+ error: `GitHub Actions gh request failed (${detail}); check \`gh auth status\`, organization membership, and billing access.`,
87
+ };
88
+ }
89
+
90
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
91
+ if (stdout.trim() === "") {
92
+ return { ok: false, error: `GitHub Actions received an empty response for ${safeText(path)}; verify gh access and retry.` };
93
+ }
94
+ try {
95
+ return { ok: true, body: JSON.parse(stdout) };
96
+ } catch {
97
+ return { ok: false, error: `GitHub Actions received malformed JSON for ${safeText(path)}; verify gh access and update piQuota if the API response changed.` };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {unknown} items
103
+ * @param {"Actions" | "actions"} product
104
+ * @param {"grossQuantity" | "quantity"} quantityField
105
+ * @returns {{ total: number, count: number } | null}
106
+ */
107
+ function sumLinuxMinutes(items, product, quantityField) {
108
+ if (!Array.isArray(items)) return null;
109
+ let total = 0;
110
+ let count = 0;
111
+ for (const item of items) {
112
+ const row = record(item);
113
+ if (!row || row.product !== product || row.unitType !== "minutes") continue;
114
+ if (typeof row.sku !== "string" || !row.sku.toLowerCase().includes("linux")) continue;
115
+ const quantity = row[quantityField];
116
+ if (typeof quantity !== "number" || !Number.isFinite(quantity) || quantity < 0) continue;
117
+ total += quantity;
118
+ count += 1;
119
+ }
120
+ return { total, count };
121
+ }
122
+
123
+ /**
124
+ * @param {unknown} items
125
+ * @param {string[]} repoFilter
126
+ * @returns {Array<{ name: string, minutes: number }> | null}
127
+ */
128
+ function repoAttributions(items, repoFilter) {
129
+ if (!Array.isArray(items)) return null;
130
+ const allowed = new Set(repoFilter);
131
+ const totals = new Map();
132
+ for (const item of items) {
133
+ const row = record(item);
134
+ if (!row || row.product !== "actions" || row.unitType !== "minutes") continue;
135
+ if (typeof row.sku !== "string" || !row.sku.toLowerCase().includes("linux")) continue;
136
+ if (typeof row.quantity !== "number" || !Number.isFinite(row.quantity) || row.quantity < 0) continue;
137
+ if (typeof row.repositoryName !== "string" || row.repositoryName.trim() === "") continue;
138
+ const name = safeText(row.repositoryName.trim());
139
+ if (repoFilter.length > 0 && !allowed.has(row.repositoryName.trim())) continue;
140
+ totals.set(name, (totals.get(name) ?? 0) + row.quantity);
141
+ }
142
+ return [...totals].map(([name, minutes]) => ({ name, minutes }))
143
+ .sort((a, b) => b.minutes - a.minutes || a.name.localeCompare(b.name));
144
+ }
145
+
146
+ /** @param {number} value */
147
+ function formatMinutes(value) {
148
+ return String(Number.isInteger(value) ? value : Number(value.toFixed(2)));
149
+ }
150
+
151
+ /**
152
+ * @param {number} now
153
+ * @returns {{ at: string, inSeconds: number }}
154
+ */
155
+ function nextMonthReset(now) {
156
+ const current = new Date(now);
157
+ const reset = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth() + 1, 1));
158
+ return { at: reset.toISOString(), inSeconds: Math.max(0, Math.round((reset.getTime() - now) / 1000)) };
159
+ }
160
+
161
+ /**
162
+ * @param {string} org
163
+ * @param {{
164
+ * now: number,
165
+ * env: Record<string, string | undefined>,
166
+ * overrideAllowance?: number,
167
+ * runCommand: typeof defaultRunCommand,
168
+ * timeoutMs: number,
169
+ * repoFilter: string[],
170
+ * }} options
171
+ * @returns {Promise<{
172
+ * ok: true,
173
+ * org: string,
174
+ * plan: string | null,
175
+ * allowance: number,
176
+ * usageTotal: number,
177
+ * usedPercent: number,
178
+ * remainingPercent: number,
179
+ * reset: { at: string, inSeconds: number },
180
+ * window: { id: string, label: string },
181
+ * note: string,
182
+ * } | { ok: false, error: string }>}
183
+ */
184
+ async function fetchOrgQuota(org, { now, env, overrideAllowance, runCommand, timeoutMs, repoFilter }) {
185
+ let allowance;
186
+ let plan = null;
187
+ let isUser = false;
188
+
189
+ if (overrideAllowance !== undefined) {
190
+ allowance = overrideAllowance;
191
+ } else {
192
+ let organization = await requestGhJson(`/orgs/${encodeURIComponent(org)}`, { runCommand, timeoutMs, env });
193
+ if (!organization.ok) {
194
+ const user = await requestGhJson(`/users/${encodeURIComponent(org)}`, { runCommand, timeoutMs, env });
195
+ if (user.ok) {
196
+ organization = user;
197
+ isUser = true;
198
+ } else {
199
+ return { ok: false, error: organization.error };
200
+ }
201
+ }
202
+ const organizationBody = record(organization.body);
203
+ const rawPlan = record(organizationBody?.plan)?.name;
204
+ const normalizedPlan = typeof rawPlan === "string" ? rawPlan.toLowerCase() : "";
205
+ const planAllowance = ALLOWANCES[normalizedPlan];
206
+ if (!planAllowance) {
207
+ return { ok: false, error: `GitHub organization plan is missing or unsupported; set PI_QUOTA_GITHUB_ACTIONS_MINUTES to a positive monthly allowance (supported plans: free, pro, team, enterprise_cloud).` };
208
+ }
209
+ allowance = planAllowance;
210
+ plan = normalizedPlan;
211
+ }
212
+
213
+ const encodedOrg = encodeURIComponent(org);
214
+ const basePath = isUser ? `/users/${encodedOrg}` : `/organizations/${encodedOrg}`;
215
+ const summary = await requestGhJson(
216
+ `${basePath}/settings/billing/usage/summary?product=Actions`,
217
+ { runCommand, timeoutMs, env },
218
+ );
219
+ if (!summary.ok) return { ok: false, error: summary.error };
220
+ const summaryBody = record(summary.body);
221
+ if (!summaryBody || !Array.isArray(summaryBody.usageItems)) {
222
+ return { ok: false, error: "GitHub Actions usage summary was empty or malformed; verify organization billing access and the GitHub API response shape." };
223
+ }
224
+ const usage = sumLinuxMinutes(summaryBody.usageItems, "Actions", "grossQuantity");
225
+ if (!usage) {
226
+ return { ok: false, error: "GitHub Actions usage summary contained no Linux minute rows; verify organization billing access and the GitHub API response shape." };
227
+ }
228
+
229
+ const current = new Date(now);
230
+ const year = current.getUTCFullYear();
231
+ const month = current.getUTCMonth() + 1;
232
+ const detailed = await requestGhJson(
233
+ `${basePath}/settings/billing/usage?year=${year}&month=${month}`,
234
+ { runCommand, timeoutMs, env },
235
+ );
236
+ if (!detailed.ok) return { ok: false, error: detailed.error };
237
+ const detailedBody = record(detailed.body);
238
+ if (!detailedBody || !Array.isArray(detailedBody.usageItems)) {
239
+ return { ok: false, error: "GitHub Actions detailed usage payload was empty or malformed; verify organization billing access and the GitHub API response shape." };
240
+ }
241
+
242
+ const attributions = repoAttributions(detailedBody.usageItems, repoFilter);
243
+ if (!attributions) {
244
+ return { ok: false, error: "GitHub Actions detailed usage payload was malformed; verify organization billing access and the GitHub API response shape." };
245
+ }
246
+
247
+ const usedPercent = Math.min(100, Math.max(0, Math.round((usage.total / allowance) * 100 * 100) / 100));
248
+ const remainingPercent = Math.min(100, Math.max(0, 100 - usedPercent));
249
+ const reset = nextMonthReset(now);
250
+ const window = windowFromSeconds(LINUX_MINUTES);
251
+ const left = Math.max(0, allowance - usage.total);
252
+ const multipleReposExist = attributions.length > 1;
253
+ const topRepos = repoFilter.length > 0 || multipleReposExist
254
+ ? attributions.slice(0, 3).map(({ name, minutes }) => `${name} ${formatMinutes(minutes)}`)
255
+ : [];
256
+ const note = `${formatMinutes(usage.total)} of ${formatMinutes(allowance)} min used | ${formatMinutes(left)} left${topRepos.length ? ` · ${topRepos.join(" · ")}` : ""}`;
257
+
258
+ return {
259
+ ok: true,
260
+ org,
261
+ plan,
262
+ allowance,
263
+ usageTotal: usage.total,
264
+ usedPercent,
265
+ remainingPercent,
266
+ reset,
267
+ window,
268
+ note,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * @param {unknown} credential Unused; GitHub authentication belongs to the gh CLI.
274
+ * @param {{
275
+ * now?: number,
276
+ * env?: Record<string, string | undefined>,
277
+ * timeoutMs?: number,
278
+ * runCommand?: typeof defaultRunCommand,
279
+ * }} [options]
280
+ * @returns {Promise<import("../model.js").QuotaResult>}
281
+ */
282
+ export async function fetchQuota(credential, options = {}) {
283
+ void credential;
284
+ const now = options.now ?? Date.now();
285
+ const env = options.env ?? process.env;
286
+ const envOrgs = env.PI_QUOTA_GITHUB_ORG?.trim();
287
+ const orgList = envOrgs
288
+ ? envOrgs.split(",").map((s) => safeText(s.trim())).filter(Boolean)
289
+ : [DEFAULT_ORG];
290
+ const orgs = orgList.length > 0 ? orgList : [DEFAULT_ORG];
291
+
292
+ const primaryOrg = orgs.join(", ");
293
+ const base = { family: FAMILY, label: LABEL, account: primaryOrg, plan: null, source: SOURCE };
294
+ const fail = (error) => degradedResult({ ...base, error: safeText(error), now });
295
+ const runCommand = options.runCommand ?? defaultRunCommand;
296
+ const timeoutMs = options.timeoutMs ?? 15_000;
297
+
298
+ try {
299
+ let overrideAllowance;
300
+ const override = env.PI_QUOTA_GITHUB_ACTIONS_MINUTES;
301
+ if (override !== undefined) {
302
+ const numericOverride = Number(override);
303
+ if (!Number.isFinite(numericOverride) || !Number.isInteger(numericOverride) || numericOverride <= 0) {
304
+ return fail("PI_QUOTA_GITHUB_ACTIONS_MINUTES must be a positive finite integer; set it to a valid monthly minutes allowance.");
305
+ }
306
+ overrideAllowance = numericOverride;
307
+ }
308
+
309
+ const repoFilter = (env.PI_QUOTA_GITHUB_ACTIONS_REPOS ?? "")
310
+ .split(",")
311
+ .map((name) => name.trim())
312
+ .filter(Boolean);
313
+
314
+ if (orgs.length === 1) {
315
+ const org = orgs[0];
316
+ const res = await fetchOrgQuota(org, { now, env, overrideAllowance, runCommand, timeoutMs, repoFilter });
317
+ if (!res.ok) return fail(res.error);
318
+ return {
319
+ ...base,
320
+ account: org,
321
+ plan: res.plan,
322
+ windows: [buildWindow({
323
+ id: res.window.id,
324
+ label: res.window.label,
325
+ usedPercent: res.usedPercent,
326
+ remainingPercent: res.remainingPercent,
327
+ resetsAt: res.reset.at,
328
+ resetsInSec: res.reset.inSeconds,
329
+ note: res.note,
330
+ now,
331
+ })],
332
+ error: null,
333
+ ok: true,
334
+ updatedAt: new Date(now).toISOString(),
335
+ };
336
+ }
337
+
338
+ const windows = [];
339
+ const plans = [];
340
+ const errors = [];
341
+ for (let i = 0; i < orgs.length; i++) {
342
+ const org = orgs[i];
343
+ const res = await fetchOrgQuota(org, { now, env, overrideAllowance, runCommand, timeoutMs, repoFilter });
344
+ if (!res.ok) {
345
+ errors.push(`${org}: ${res.error}`);
346
+ continue;
347
+ }
348
+ if (res.plan) plans.push(res.plan);
349
+ const windowId = i === 0 ? "monthly" : `monthly-${org.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
350
+ windows.push(buildWindow({
351
+ id: windowId,
352
+ label: `${org} monthly`,
353
+ usedPercent: res.usedPercent,
354
+ remainingPercent: res.remainingPercent,
355
+ resetsAt: res.reset.at,
356
+ resetsInSec: res.reset.inSeconds,
357
+ note: `${org}: ${res.note}`,
358
+ now,
359
+ }));
360
+ }
361
+
362
+ if (windows.length === 0) {
363
+ return fail(errors.join("; "));
364
+ }
365
+
366
+ const uniquePlans = Array.from(new Set(plans));
367
+ return {
368
+ ...base,
369
+ account: orgs.join(", "),
370
+ plan: uniquePlans.join(", ") || null,
371
+ windows,
372
+ error: null,
373
+ ok: true,
374
+ updatedAt: new Date(now).toISOString(),
375
+ };
376
+ } catch (error) {
377
+ return fail(`GitHub Actions provider could not complete the gh CLI request; check gh installation, authentication, and organization billing access. ${safeText(error?.message ?? error)}`);
378
+ }
379
+ }