claude-dev-env 1.78.0 → 1.80.0
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.
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
- package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
- package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
- package/bin/install.mjs +1 -0
- package/bin/install.test.mjs +3 -2
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/code_rules_docstrings.py +609 -16
- package/hooks/blocking/code_rules_enforcer.py +41 -0
- package/hooks/blocking/code_rules_imports_logging.py +136 -1
- package/hooks/blocking/code_rules_naming_collection.py +76 -1
- package/hooks/blocking/code_rules_paired_test.py +240 -22
- package/hooks/blocking/code_rules_test_assertions.py +159 -1
- package/hooks/blocking/code_rules_unused_imports.py +7 -63
- package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
- package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
- package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +96 -15
- package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +79 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
- package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
- package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
- package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +57 -54
- package/rules/env-var-table-code-drift.md +24 -0
- package/rules/package-inventory-stale-entry.md +4 -4
- package/rules/paired-test-coverage.md +12 -5
- package/skills/autoconverge/SKILL.md +26 -5
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
- package/skills/autoconverge/workflow/converge.mjs +110 -228
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
- package/skills/pr-converge/SKILL.md +28 -2
- package/skills/pr-converge/reference/convergence-gates.md +13 -1
- package/skills/pr-converge/reference/state-schema.md +11 -0
|
@@ -13,6 +13,7 @@ Python scripts invoked at runtime by the PR-loop skills. Each script is a standa
|
|
|
13
13
|
| `revoke_project_claude_permissions.py` | Removes the allow-rules and entries that `grant_project_claude_permissions.py` wrote; safe to run when no prior grant exists |
|
|
14
14
|
| `code_rules_gate.py` | Pre-commit gate that runs `code_rules_enforcer` checks on staged Python files before a fix commit lands |
|
|
15
15
|
| `reviews_disabled.py` | Shared helper for the `CLAUDE_REVIEWS_DISABLED` opt-out gate; parses the env-var token to find which reviewer types are suppressed |
|
|
16
|
+
| `copilot_quota.py` | Copilot premium-request quota pre-check: resolves a configured GitHub account, reads its remaining `premium_interactions` quota via `gh api copilot_internal/user`, and exits 0 (run Copilot) or non-zero (skip: out of quota, API down, or no account configured) |
|
|
16
17
|
| `fix_hookspath.py` | Repairs a malformed `core.hooksPath` global git config entry |
|
|
17
18
|
| `_claude_permissions_common.py` | Internal helpers shared by the grant/revoke scripts: atomic settings.json writes, list mutation, path helpers |
|
|
18
19
|
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Copilot premium-interaction quota pre-check.
|
|
2
|
+
|
|
3
|
+
Run this before any Copilot review step spawns. It reads a configured GitHub
|
|
4
|
+
account's remaining premium-interaction quota and decides whether Copilot has
|
|
5
|
+
quota to run. An account already out of quota skips the Copilot step instead of
|
|
6
|
+
wasting a whole review cycle on it.
|
|
7
|
+
|
|
8
|
+
Account resolution order: the ``--account`` flag, then the
|
|
9
|
+
``COPILOT_QUOTA_ACCOUNT`` environment variable, then that same key parsed from a
|
|
10
|
+
git-ignored ``.env`` file. The account's ``gh`` token is resolved with
|
|
11
|
+
``gh auth token -u <account>`` and the quota is read from
|
|
12
|
+
``gh api copilot_internal/user``. The config file names the account only and
|
|
13
|
+
stores no secret.
|
|
14
|
+
|
|
15
|
+
The exit code tells the caller what to do. Exit 0 means run Copilot. A non-zero
|
|
16
|
+
exit means skip it: the account is out of quota (scenario A), the quota API or
|
|
17
|
+
account access is down (scenario B), or no account is configured (scenario C).
|
|
18
|
+
Every path prints one line: the run line to stdout, each skip line to stderr.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from pr_loop_shared_constants.copilot_quota_constants import (
|
|
32
|
+
COPILOT_INTERNAL_USER_API_PATH,
|
|
33
|
+
COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME,
|
|
34
|
+
COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH,
|
|
35
|
+
EXIT_CODE_NO_ACCOUNT_CONFIGURED,
|
|
36
|
+
EXIT_CODE_OUT_OF_QUOTA,
|
|
37
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
38
|
+
EXIT_CODE_QUOTA_AVAILABLE,
|
|
39
|
+
GH_TOKEN_ENV_VAR_NAME,
|
|
40
|
+
PREMIUM_ENTITLEMENT_FIELD_NAME,
|
|
41
|
+
PREMIUM_INTERACTIONS_FIELD_NAME,
|
|
42
|
+
PREMIUM_OVERAGE_PERMITTED_FIELD_NAME,
|
|
43
|
+
PREMIUM_PERCENT_REMAINING_FIELD_NAME,
|
|
44
|
+
PREMIUM_REMAINING_FIELD_NAME,
|
|
45
|
+
PREMIUM_UNLIMITED_FIELD_NAME,
|
|
46
|
+
QUOTA_SNAPSHOTS_FIELD_NAME,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class QuotaDecision:
|
|
52
|
+
"""One quota pre-check outcome: the exit code and the single log line.
|
|
53
|
+
|
|
54
|
+
The exit code tells the caller what to do, where 0 runs Copilot and any
|
|
55
|
+
non-zero skips it. The CLI prints the message on one line, to stdout on a
|
|
56
|
+
run and to stderr on a skip.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
exit_code: int
|
|
60
|
+
message: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run_gh(
|
|
64
|
+
all_command_arguments: list[str],
|
|
65
|
+
all_environment_overrides: dict[str, str] | None = None,
|
|
66
|
+
) -> tuple[int, str]:
|
|
67
|
+
"""Run a ``gh`` subprocess and return its exit code and stdout.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
all_command_arguments: Arguments after the ``gh`` program name, for
|
|
71
|
+
example ``["api", "copilot_internal/user"]``.
|
|
72
|
+
all_environment_overrides: Environment entries to overlay on the current
|
|
73
|
+
environment for this call, or None to inherit it unchanged.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The subprocess exit code paired with its captured stdout.
|
|
77
|
+
"""
|
|
78
|
+
process_environment = dict(os.environ)
|
|
79
|
+
if all_environment_overrides:
|
|
80
|
+
process_environment.update(all_environment_overrides)
|
|
81
|
+
completed_process = subprocess.run(
|
|
82
|
+
["gh", *all_command_arguments],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
encoding="utf-8",
|
|
86
|
+
errors="replace",
|
|
87
|
+
check=False,
|
|
88
|
+
env=process_environment,
|
|
89
|
+
)
|
|
90
|
+
return completed_process.returncode, completed_process.stdout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_account_from_env_file(env_file_path: Path) -> str | None:
|
|
94
|
+
"""Read the configured account from a git-ignored ``.env`` file.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
env_file_path: Path to the ``.env`` file that may carry a
|
|
98
|
+
``COPILOT_QUOTA_ACCOUNT=<login>`` line.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The configured account login, or None when the file is absent,
|
|
102
|
+
unreadable, or carries no non-empty account line.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
file_text = env_file_path.read_text(encoding="utf-8")
|
|
106
|
+
except (OSError, UnicodeDecodeError):
|
|
107
|
+
return None
|
|
108
|
+
for each_line in file_text.splitlines():
|
|
109
|
+
stripped_line = each_line.strip()
|
|
110
|
+
if not stripped_line or stripped_line.startswith("#"):
|
|
111
|
+
continue
|
|
112
|
+
key_text, separator, raw_account = stripped_line.partition("=")
|
|
113
|
+
if not separator or key_text.strip() != COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME:
|
|
114
|
+
continue
|
|
115
|
+
resolved_login = raw_account.strip().strip('"').strip("'")
|
|
116
|
+
if resolved_login:
|
|
117
|
+
return resolved_login
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_account(cli_account: str | None, env_file_path: Path) -> str | None:
|
|
122
|
+
"""Resolve the account whose Copilot quota to check.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
cli_account: The ``--account`` value, or None when the flag is absent.
|
|
126
|
+
env_file_path: The ``.env`` file consulted when neither the flag nor
|
|
127
|
+
the environment variable names an account.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The resolved account login from the flag, then the
|
|
131
|
+
``COPILOT_QUOTA_ACCOUNT`` environment variable, then the ``.env``
|
|
132
|
+
file, or None when none of the three names an account.
|
|
133
|
+
"""
|
|
134
|
+
if cli_account and cli_account.strip():
|
|
135
|
+
return cli_account.strip()
|
|
136
|
+
environment_account = os.environ.get(COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME, "").strip()
|
|
137
|
+
if environment_account:
|
|
138
|
+
return environment_account
|
|
139
|
+
return _read_account_from_env_file(env_file_path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_gh_token(account_login: str) -> str | None:
|
|
143
|
+
"""Resolve the account's gh token via ``gh auth token -u <account>``.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
account_login: The GitHub login to resolve a token for.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The token string, or None when ``gh`` exits non-zero or prints no
|
|
150
|
+
token, so an account that is not ``gh auth login``-ed reads as down.
|
|
151
|
+
"""
|
|
152
|
+
returncode, stdout = _run_gh(["auth", "token", "-u", account_login])
|
|
153
|
+
if returncode != 0:
|
|
154
|
+
return None
|
|
155
|
+
token = stdout.strip()
|
|
156
|
+
return token or None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fetch_copilot_user(token: str) -> dict[str, object] | None:
|
|
160
|
+
"""Read the ``copilot_internal/user`` payload for a resolved token.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
token: A gh token authorized for the account under check.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The parsed response object, or None when ``gh`` exits non-zero or
|
|
167
|
+
returns a body that is not a JSON object.
|
|
168
|
+
"""
|
|
169
|
+
returncode, stdout = _run_gh(
|
|
170
|
+
["api", COPILOT_INTERNAL_USER_API_PATH],
|
|
171
|
+
{GH_TOKEN_ENV_VAR_NAME: token},
|
|
172
|
+
)
|
|
173
|
+
if returncode != 0:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
parsed_payload = json.loads(stdout)
|
|
177
|
+
except json.JSONDecodeError:
|
|
178
|
+
return None
|
|
179
|
+
if not isinstance(parsed_payload, dict):
|
|
180
|
+
return None
|
|
181
|
+
return parsed_payload
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _extract_premium_snapshot(
|
|
185
|
+
all_user_fields: dict[str, object],
|
|
186
|
+
) -> dict[str, object] | None:
|
|
187
|
+
"""Pull the ``premium_interactions`` quota snapshot from the user payload.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
all_user_fields: The parsed ``copilot_internal/user`` response object.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The ``quota_snapshots.premium_interactions`` object, or None when
|
|
194
|
+
either level is missing or is not an object.
|
|
195
|
+
"""
|
|
196
|
+
quota_snapshots = all_user_fields.get(QUOTA_SNAPSHOTS_FIELD_NAME)
|
|
197
|
+
if not isinstance(quota_snapshots, dict):
|
|
198
|
+
return None
|
|
199
|
+
premium_snapshot = quota_snapshots.get(PREMIUM_INTERACTIONS_FIELD_NAME)
|
|
200
|
+
if not isinstance(premium_snapshot, dict):
|
|
201
|
+
return None
|
|
202
|
+
return premium_snapshot
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_premium_quota_exhausted(all_premium_fields: dict[str, object]) -> bool:
|
|
206
|
+
"""Report whether the premium-interaction quota is exhausted.
|
|
207
|
+
|
|
208
|
+
The quota counts as exhausted when it is not unlimited, its remaining count
|
|
209
|
+
has reached zero, and overage is not permitted. That is the one state in
|
|
210
|
+
which a Copilot review request cannot be served. Any missing or malformed
|
|
211
|
+
gating field leaves the quota reported as not exhausted, so the caller runs
|
|
212
|
+
Copilot rather than skipping on an unreadable snapshot.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
all_premium_fields: The ``premium_interactions`` quota snapshot object.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True when unlimited is False, remaining is at or below zero, and overage
|
|
219
|
+
is not permitted.
|
|
220
|
+
"""
|
|
221
|
+
is_unlimited = all_premium_fields.get(PREMIUM_UNLIMITED_FIELD_NAME)
|
|
222
|
+
is_overage_permitted = all_premium_fields.get(PREMIUM_OVERAGE_PERMITTED_FIELD_NAME)
|
|
223
|
+
remaining = all_premium_fields.get(PREMIUM_REMAINING_FIELD_NAME)
|
|
224
|
+
if not isinstance(is_unlimited, bool) or not isinstance(is_overage_permitted, bool):
|
|
225
|
+
return False
|
|
226
|
+
if isinstance(remaining, bool) or not isinstance(remaining, (int, float)):
|
|
227
|
+
return False
|
|
228
|
+
return is_unlimited is False and remaining <= 0 and is_overage_permitted is False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _describe_quota_amount(all_premium_fields: dict[str, object]) -> str:
|
|
232
|
+
"""Render the remaining/entitlement/percent fragment for a snapshot.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
all_premium_fields: The ``premium_interactions`` quota snapshot object.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
A fragment naming the remaining count over the entitlement with the
|
|
239
|
+
percent remaining, built from the snapshot's remaining, entitlement,
|
|
240
|
+
and percent-remaining fields.
|
|
241
|
+
"""
|
|
242
|
+
remaining = all_premium_fields.get(PREMIUM_REMAINING_FIELD_NAME)
|
|
243
|
+
entitlement = all_premium_fields.get(PREMIUM_ENTITLEMENT_FIELD_NAME)
|
|
244
|
+
percent_remaining = all_premium_fields.get(PREMIUM_PERCENT_REMAINING_FIELD_NAME)
|
|
245
|
+
return f"{remaining}/{entitlement} remaining ({percent_remaining}%)"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def evaluate_copilot_quota(
|
|
249
|
+
cli_account: str | None, env_file_path: Path
|
|
250
|
+
) -> QuotaDecision:
|
|
251
|
+
"""Decide whether Copilot has premium quota to run for a configured account.
|
|
252
|
+
|
|
253
|
+
Resolves the account, reads its remaining ``premium_interactions`` quota via
|
|
254
|
+
``gh``, and maps the result to one of four outcomes: premium quota available
|
|
255
|
+
(run Copilot), out of quota, quota API or account access down, and no
|
|
256
|
+
account configured.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
cli_account: The ``--account`` value, or None when the flag is absent.
|
|
260
|
+
env_file_path: The ``.env`` file consulted for the account when neither
|
|
261
|
+
the flag nor the environment variable names one.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
A QuotaDecision carrying the exit code and the single log line for the
|
|
265
|
+
resolved outcome.
|
|
266
|
+
"""
|
|
267
|
+
account_login = _resolve_account(cli_account, env_file_path)
|
|
268
|
+
if account_login is None:
|
|
269
|
+
return QuotaDecision(
|
|
270
|
+
EXIT_CODE_NO_ACCOUNT_CONFIGURED,
|
|
271
|
+
f"copilot-quota: no account configured — set "
|
|
272
|
+
f"{COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME}=<login> in {env_file_path} "
|
|
273
|
+
f"(or pass --account). Skipping Copilot (scenario C).",
|
|
274
|
+
)
|
|
275
|
+
token = _resolve_gh_token(account_login)
|
|
276
|
+
if token is None:
|
|
277
|
+
return QuotaDecision(
|
|
278
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
279
|
+
f"copilot-quota: could not resolve a gh token for '{account_login}' "
|
|
280
|
+
f"(is it gh auth login-ed?) — skipping Copilot (scenario B).",
|
|
281
|
+
)
|
|
282
|
+
all_user_fields = _fetch_copilot_user(token)
|
|
283
|
+
if all_user_fields is None:
|
|
284
|
+
return QuotaDecision(
|
|
285
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
286
|
+
f"copilot-quota: gh api {COPILOT_INTERNAL_USER_API_PATH} failed or "
|
|
287
|
+
f"returned non-JSON for '{account_login}' — skipping Copilot "
|
|
288
|
+
f"(scenario B).",
|
|
289
|
+
)
|
|
290
|
+
premium_snapshot = _extract_premium_snapshot(all_user_fields)
|
|
291
|
+
if premium_snapshot is None:
|
|
292
|
+
return QuotaDecision(
|
|
293
|
+
EXIT_CODE_QUOTA_API_DOWN,
|
|
294
|
+
f"copilot-quota: the {COPILOT_INTERNAL_USER_API_PATH} response for "
|
|
295
|
+
f"'{account_login}' carried no "
|
|
296
|
+
f"{QUOTA_SNAPSHOTS_FIELD_NAME}.{PREMIUM_INTERACTIONS_FIELD_NAME} — "
|
|
297
|
+
f"skipping Copilot (scenario B).",
|
|
298
|
+
)
|
|
299
|
+
if _is_premium_quota_exhausted(premium_snapshot):
|
|
300
|
+
return QuotaDecision(
|
|
301
|
+
EXIT_CODE_OUT_OF_QUOTA,
|
|
302
|
+
f"copilot-quota: {account_login} is out of premium-interaction "
|
|
303
|
+
f"quota — {_describe_quota_amount(premium_snapshot)}, overage not "
|
|
304
|
+
f"permitted — skipping Copilot (scenario A).",
|
|
305
|
+
)
|
|
306
|
+
return QuotaDecision(
|
|
307
|
+
EXIT_CODE_QUOTA_AVAILABLE,
|
|
308
|
+
f"copilot-quota: {account_login} — premium interactions "
|
|
309
|
+
f"{_describe_quota_amount(premium_snapshot)} — running Copilot.",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_arguments(all_argv: list[str]) -> argparse.Namespace:
|
|
314
|
+
"""Parse command-line arguments for the quota pre-check.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
all_argv: Argument list excluding the program name, typically
|
|
318
|
+
``sys.argv[1:]``.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Namespace exposing an ``account`` attribute holding the ``--account``
|
|
322
|
+
value, or None when the flag is absent.
|
|
323
|
+
"""
|
|
324
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
325
|
+
parser.add_argument(
|
|
326
|
+
"--account",
|
|
327
|
+
default=None,
|
|
328
|
+
help=(
|
|
329
|
+
"GitHub login whose Copilot premium-interaction quota to check; "
|
|
330
|
+
"overrides COPILOT_QUOTA_ACCOUNT and the .env file"
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
return parser.parse_args(all_argv)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main(all_arguments: list[str]) -> int:
|
|
337
|
+
"""Run the quota pre-check end-to-end and print its one-line decision.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
all_arguments: Argument list excluding the program name.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The pre-check exit code: 0 to run Copilot, or a non-zero skip code for
|
|
344
|
+
out-of-quota (scenario A), quota API or access down (scenario B), and no
|
|
345
|
+
account configured (scenario C).
|
|
346
|
+
"""
|
|
347
|
+
arguments = _parse_arguments(all_arguments)
|
|
348
|
+
decision = evaluate_copilot_quota(
|
|
349
|
+
cli_account=arguments.account,
|
|
350
|
+
env_file_path=COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH,
|
|
351
|
+
)
|
|
352
|
+
message_stream = (
|
|
353
|
+
sys.stdout if decision.exit_code == EXIT_CODE_QUOTA_AVAILABLE else sys.stderr
|
|
354
|
+
)
|
|
355
|
+
print(decision.message, file=message_stream)
|
|
356
|
+
return decision.exit_code
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__":
|
|
360
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -15,6 +15,7 @@ Named constants for every script in `_shared/pr-loop/scripts/`. Each module owns
|
|
|
15
15
|
| `preflight_constants.py` | `preflight.py` — env-var names, git subcommands, pytest exit codes, test discovery patterns |
|
|
16
16
|
| `preflight_self_heal_constants.py` | `preflight_self_heal.py` — git config keys and local-scope detection strings |
|
|
17
17
|
| `reviews_disabled_constants.py` | `reviews_disabled.py` — `CLAUDE_REVIEWS_DISABLED` token taxonomy |
|
|
18
|
+
| `copilot_quota_constants.py` | `copilot_quota.py` — `COPILOT_QUOTA_ACCOUNT` env-var name, `copilot_internal/user` API path, the `premium_interactions` gating field names, the four skip/run exit codes, and the default `.env` path |
|
|
18
19
|
| `__init__.py` | Empty package marker |
|
|
19
20
|
|
|
20
21
|
## Convention
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration constants for the Copilot premium-interaction quota pre-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
COPILOT_QUOTA_ACCOUNT_ENV_VAR_NAME: str = "COPILOT_QUOTA_ACCOUNT"
|
|
8
|
+
GH_TOKEN_ENV_VAR_NAME: str = "GH_TOKEN"
|
|
9
|
+
COPILOT_INTERNAL_USER_API_PATH: str = "copilot_internal/user"
|
|
10
|
+
|
|
11
|
+
QUOTA_SNAPSHOTS_FIELD_NAME: str = "quota_snapshots"
|
|
12
|
+
PREMIUM_INTERACTIONS_FIELD_NAME: str = "premium_interactions"
|
|
13
|
+
PREMIUM_UNLIMITED_FIELD_NAME: str = "unlimited"
|
|
14
|
+
PREMIUM_REMAINING_FIELD_NAME: str = "remaining"
|
|
15
|
+
PREMIUM_OVERAGE_PERMITTED_FIELD_NAME: str = "overage_permitted"
|
|
16
|
+
PREMIUM_ENTITLEMENT_FIELD_NAME: str = "entitlement"
|
|
17
|
+
PREMIUM_PERCENT_REMAINING_FIELD_NAME: str = "percent_remaining"
|
|
18
|
+
|
|
19
|
+
EXIT_CODE_QUOTA_AVAILABLE: int = 0
|
|
20
|
+
EXIT_CODE_OUT_OF_QUOTA: int = 1
|
|
21
|
+
EXIT_CODE_QUOTA_API_DOWN: int = 2
|
|
22
|
+
EXIT_CODE_NO_ACCOUNT_CONFIGURED: int = 3
|
|
23
|
+
|
|
24
|
+
COPILOT_QUOTA_DEFAULT_ENV_FILE_PATH: Path = Path(__file__).resolve().parents[4] / ".env"
|
|
@@ -21,10 +21,17 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
|
|
|
21
21
|
| `test_preflight_constants.py` | `pr_loop_shared_constants/preflight_constants.py` |
|
|
22
22
|
| `test_preflight_self_heal.py` | `preflight_self_heal.py` hooks-path repair |
|
|
23
23
|
| `test_reviews_disabled.py` | `reviews_disabled.py` opt-out gate parsing |
|
|
24
|
+
| `test_copilot_quota.py` | `copilot_quota.py` end-to-end: account resolution, premium-quota classification, exit codes, and skip logging |
|
|
25
|
+
| `test_copilot_quota_constants.py` | `pr_loop_shared_constants/copilot_quota_constants.py` |
|
|
24
26
|
| `test_revoke_project_claude_permissions.py` | `revoke_project_claude_permissions.py` end-to-end |
|
|
25
27
|
| `test_agent_config_carveout.py` | Agent-config deny-rule carve-out logic |
|
|
26
28
|
| `conftest.py` | Shared pytest fixtures |
|
|
27
29
|
|
|
30
|
+
## Fixtures
|
|
31
|
+
|
|
32
|
+
`fixtures/copilot_internal_user_jonecho.json` — a captured `gh api
|
|
33
|
+
copilot_internal/user` response driving `test_copilot_quota.py`.
|
|
34
|
+
|
|
28
35
|
## Running
|
|
29
36
|
|
|
30
37
|
```bash
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"login": "jonecho",
|
|
3
|
+
"access_type_sku": "plus_monthly_subscriber_quota",
|
|
4
|
+
"assigned_date": "2026-05-09T14:08:29-04:00",
|
|
5
|
+
"can_signup_for_limited": false,
|
|
6
|
+
"chat_enabled": true,
|
|
7
|
+
"cli_enabled": true,
|
|
8
|
+
"copilotignore_enabled": false,
|
|
9
|
+
"copilot_plan": "individual_pro",
|
|
10
|
+
"editor_preview_features_enabled": true,
|
|
11
|
+
"is_mcp_enabled": true,
|
|
12
|
+
"is_staff": false,
|
|
13
|
+
"organization_login_list": [],
|
|
14
|
+
"organization_list": [],
|
|
15
|
+
"restricted_telemetry": false,
|
|
16
|
+
"cli_remote_control_enabled": true,
|
|
17
|
+
"cloud_session_storage_enabled": true,
|
|
18
|
+
"endpoints": {
|
|
19
|
+
"api": "https://api.individual.githubcopilot.com",
|
|
20
|
+
"origin-tracker": "https://origin-tracker.individual.githubcopilot.com",
|
|
21
|
+
"proxy": "https://proxy.individual.githubcopilot.com",
|
|
22
|
+
"telemetry": "https://telemetry.individual.githubcopilot.com"
|
|
23
|
+
},
|
|
24
|
+
"can_upgrade_plan": true,
|
|
25
|
+
"codex_agent_enabled": true,
|
|
26
|
+
"quota_reset_date": "2026-08-01",
|
|
27
|
+
"quota_snapshots": {
|
|
28
|
+
"chat": {
|
|
29
|
+
"overage_count": 0,
|
|
30
|
+
"overage_entitlement": 0,
|
|
31
|
+
"overage_permitted": false,
|
|
32
|
+
"percent_remaining": 100.0,
|
|
33
|
+
"quota_id": "chat",
|
|
34
|
+
"quota_remaining": 0.0,
|
|
35
|
+
"unlimited": true,
|
|
36
|
+
"timestamp_utc": "2026-07-01T11:05:17.669Z",
|
|
37
|
+
"has_quota": true,
|
|
38
|
+
"quota_reset_at": 0,
|
|
39
|
+
"token_based_billing": true,
|
|
40
|
+
"remaining": 0,
|
|
41
|
+
"entitlement": 0
|
|
42
|
+
},
|
|
43
|
+
"completions": {
|
|
44
|
+
"overage_count": 0,
|
|
45
|
+
"overage_entitlement": 0,
|
|
46
|
+
"overage_permitted": false,
|
|
47
|
+
"percent_remaining": 100.0,
|
|
48
|
+
"quota_id": "completions",
|
|
49
|
+
"quota_remaining": 0.0,
|
|
50
|
+
"unlimited": true,
|
|
51
|
+
"timestamp_utc": "2026-07-01T11:05:17.669Z",
|
|
52
|
+
"has_quota": true,
|
|
53
|
+
"quota_reset_at": 0,
|
|
54
|
+
"token_based_billing": true,
|
|
55
|
+
"remaining": 0,
|
|
56
|
+
"entitlement": 0
|
|
57
|
+
},
|
|
58
|
+
"premium_interactions": {
|
|
59
|
+
"overage_count": 0,
|
|
60
|
+
"overage_entitlement": 0,
|
|
61
|
+
"overage_permitted": false,
|
|
62
|
+
"percent_remaining": 97.3,
|
|
63
|
+
"quota_id": "premium_interactions",
|
|
64
|
+
"quota_remaining": 6817.0,
|
|
65
|
+
"unlimited": false,
|
|
66
|
+
"timestamp_utc": "2026-07-01T11:05:17.669Z",
|
|
67
|
+
"has_quota": true,
|
|
68
|
+
"quota_reset_at": 0,
|
|
69
|
+
"token_based_billing": true,
|
|
70
|
+
"remaining": 6817,
|
|
71
|
+
"entitlement": 7000
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"quota_reset_date_utc": "2026-08-01T00:00:00.000Z",
|
|
75
|
+
"token_based_billing": true
|
|
76
|
+
}
|